Security advisory GHSA-xwqr-rcqg-22mr: Flight vulnerable to SQL Injection via unvalidated identifiers in SimplePdo::insert / update / delete
Package: flightphp/core (composer), vulnerable version: v3.18.0, fixed in: 3.18.1
CWE: CWE-89
Files changed by the fix:
flight/Engine.php
flight/commands/ControllerCommand.php
flight/database/SimplePdo.php
flight/net/Request.php

Fix diff:
diff --git a/flight/Engine.php b/flight/Engine.php
index a74a21d6..5ceb7639 100644
--- a/flight/Engine.php
+++ b/flight/Engine.php
@@ -204,10 +204,12 @@ public function init(): void
         $this->set('flight.case_sensitive', false);
         $this->set('flight.handle_errors', true);
         $this->set('flight.log_errors', false);
+        $this->set('flight.debug', false);
         $this->set('flight.views.path', './views');
         $this->set('flight.views.extension', '.php');
         $this->set('flight.content_length', true);
         $this->set('flight.v2.output_buffering', false);
+        $this->set('flight.allow_method_override', true);
 
         // Startup configuration
         $this->before('start', function () use ($self) {
@@ -225,6 +227,8 @@ public function init(): void
             // which causes a lot of problems. This will be removed
             // in v4
             $self->response()->v2_output_buffering = $this->get('flight.v2.output_buffering');
+            // Propagate method override setting to Request
+            $self->request()::$allowMethodOverride = (bool) $self->get('flight.allow_method_override');
         });
 
         $this->initialized = true;
@@ -678,16 +682,24 @@ public function _start(): void
     public function _error(Throwable $e): void
     {
         $this->triggerEvent('flight.error', $e);
-        $msg = sprintf(
-            <<<'HTML'
-            <h1>500 Internal Server Error</h1>
-                <h3>%s (%s)</h3>
-                <pre>%s</pre>
-            HTML, // phpcs:ignore
-            $e->getMessage(),
-            $e->getCode(),
-            $e->getTraceAsString()
-        );
+
+        if ($this->get('flight.debug') === true) {
+            $msg = sprintf(
+                <<<'HTML'
+                <h1>500 Internal Server Error</h1>
+                    <h3>%s (%s)</h3>
+                    <pre>%s</pre>
+                HTML, // phpcs:ignore
+                htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'),
+                $e->getCode(),
+                htmlspecialchars($e->getTraceAsString(), ENT_QUOTES, 'UTF-8')
+            );
+        } else {
+            if ($this->get('flight.log_errors') === true) {
+                error_log($e->getMessage() . "\n" . $e->getTraceAsString());
+            }
+            $msg = '<h1>500 Internal Server Error</h1>';
+        }
 
         try {
             $this->response()
@@ -890,7 +902,7 @@ public function _redirect(string $url, int $code = 303): void
         }
 
         // Append base url to redirect url
-        if ($base !== '/'   && strpos($url, '://') === false) {
+        if ($base !== '/' && strpos($url, '://') === false) {
             $url = $base . preg_replace('#/+#', '/', '/' . $url);
         }
 
@@ -1001,7 +1013,11 @@ public function _jsonp(
         int $option = 0
     ): void {
         $json = $encode ? Json::encode($data, $option) : $data;
-        $callback = $this->request()->query[$param];
+        $callback = (string) $this->request()->query[$param];
+
+        if ($callback !== '' && !preg_match('/^[A-Za-z_$][\w$.]{0,127}$/', $callback)) {
+            throw new Exception('Invalid JSONP callback name.');
+        }
 
         $this->response()
             ->status($code)
diff --git a/flight/commands/ControllerCommand.php b/flight/commands/ControllerCommand.php
index fb2eb890..4a676bc4 100644
--- a/flight/commands/ControllerCommand.php
+++ b/flight/commands/ControllerCommand.php
@@ -50,6 +50,13 @@ public function execute(string $controller): void
             return;
         }
 
+        $controller = basename($controller);
+
+        if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', str_replace('Controller', '', $controller))) {
+            $io->error('Controller name must contain only letters, numbers, and underscores.', true);
+            return;
+        }
+
         if (!preg_match('/Controller$/', $controller)) {
             $controller .= 'Controller';
         }
diff --git a/flight/database/SimplePdo.php b/flight/database/SimplePdo.php
index e4ba2b8e..f7015963 100644
--- a/flight/database/SimplePdo.php
+++ b/flight/database/SimplePdo.php
@@ -55,6 +55,17 @@ public function __construct(
         }
     }
 
+    /**
+     * Validates that an SQL identifier (table or column name) is safe for interpolation.
+     * Throws PDOException on invalid identifier to prevent SQL injection.
+     */
+    protected function requireSafeIdentifier(string $identifier): void
+    {
+        if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier)) {
+            throw new PDOException("Unsafe SQL identifier: '$identifier'");
+        }
+    }
+
     /**
      * Pulls one row from the query
      *
@@ -319,6 +330,8 @@ public function transaction(callable $callback)
      */
     public function insert(string $table, array $data): string
     {
+        $this->requireSafeIdentifier($table);
+
         // Detect if this is a bulk insert (array of arrays)
         $isBulk = isset($data[0]) && is_array($data[0]);
 
@@ -333,6 +346,10 @@ public function insert(string $table, array $data): string
             $columns = array_keys($firstRow);
             $columnCount = count($columns);
 
+            foreach ($columns as $col) {
+                $this->requireSafeIdentifier((string) $col);
+            }
+
             // Validate all rows have same columns
             foreach ($data as $index => $row) {
                 if (count($row) !== $columnCount) {
@@ -363,6 +380,11 @@ public function insert(string $table, array $data): string
         } else {
             // Single insert
             $columns = array_keys($data);
+
+            foreach ($columns as $col) {
+                $this->requireSafeIdentifier((string) $col);
+            }
+
             $placeholders = array_fill(0, count($data), '?');
 
             $sql = sprintf(
@@ -396,8 +418,11 @@ public function insert(string $table, array $data): string
      */
     public function update(string $table, array $data, string $where, array $whereParams = []): int
     {
+        $this->requireSafeIdentifier($table);
+
         $sets = [];
         foreach (array_keys($data) as $column) {
+            $this->requireSafeIdentifier((string) $column);
             $sets[] = "$column = ?";
         }
 
@@ -426,6 +451,7 @@ public function update(string $table, array $data, string $where, array $wherePa
      */
     public function delete(string $table, string $where, array $whereParams = []): int
     {
+        $this->requireSafeIdentifier($table);
         $sql = "DELETE FROM $table WHERE $where";
         $stmt = $this->runQuery($sql, $whereParams);
         return $stmt->rowCount();
diff --git a/flight/net/Request.php b/flight/net/Request.php
index 2e4c0368..fdaa7cb5 100644
--- a/flight/net/Request.php
+++ b/flight/net/Request.php
@@ -137,6 +137,12 @@ class Request
      */
     public string $servername;
 
+    /**
+     * Whether to allow HTTP method override via X-HTTP-Method-Override header or _method POST field.
+     * Controlled by the flight.allow_method_override engine setting.
+     */
+    public static bool $allowMethodOverride = true;
+
     /**
      * Stream path for where to pull the request body from
      */
@@ -282,10 +288,12 @@ public static function getMethod(): string
     {
         $method = self::getVar('REQUEST_METHOD', 'GET');
 
-        if (self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE') !== '') {
-            $method = self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE');
-        } elseif (isset($_REQUEST['_method']) === true) {
-            $method = $_REQUEST['_method'];
+        if (self::$allowMethodOverride === true) {
+            if (self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE') !== '') {
+                $method = self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE');
+            } elseif (isset($_REQUEST['_method']) === true) {
+                $method = $_REQUEST['_method'];
+            }
         }
 
         return strtoupper($method);
diff --git a/tests/EngineTest.php b/tests/EngineTest.php
index d89e453c..c374845c 100644
--- a/tests/EngineTest.php
+++ b/tests/EngineTest.php
@@ -87,6 +87,14 @@ public function testHandleErrorWithException(): void
     public function testHandleException(): void
     {
         $engine = new Engine();
+        $this->expectOutputRegex('~\<h1\>500 Internal Server Error\</h1\>~');
+        $engine->handleException(new Exception('thrown exception message', 20));
+    }
+
+    public function testHandleExceptionDebugMode(): void
+    {
+        $engine = new Engine();
+        $engine->set('flight.debug', true);
         $this->expectOutputRegex('~\<h1\>500 Internal Server Error\</h1\>[\s\S]*\<h3\>thrown exception message \(20\)\</h3\>~');
         $engine->handleException(new Exception('thrown exception message', 20));
     }