diff --git a/flight/Engine.php b/flight/Engine.php
index a74a21d..5ceb763 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'
-
500 Internal Server Error
- %s (%s)
- %s
- HTML, // phpcs:ignore
- $e->getMessage(),
- $e->getCode(),
- $e->getTraceAsString()
- );
+
+ if ($this->get('flight.debug') === true) {
+ $msg = sprintf(
+ <<<'HTML'
+ 500 Internal Server Error
+ %s (%s)
+ %s
+ 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 = '500 Internal Server Error
';
+ }
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 fb2eb89..4a676bc 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 e4ba2b8..f701596 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 2e4c036..fdaa7cb 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 d89e453..c374845 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('~\500 Internal Server Error\
~');
+ $engine->handleException(new Exception('thrown exception message', 20));
+ }
+
+ public function testHandleExceptionDebugMode(): void
+ {
+ $engine = new Engine();
+ $engine->set('flight.debug', true);
$this->expectOutputRegex('~\500 Internal Server Error\
[\s\S]*\thrown exception message \(20\)\
~');
$engine->handleException(new Exception('thrown exception message', 20));
}