From da7c680962b0bdd283bd2350e629776f8bb79fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Metin=20=C5=9Eim=C5=9Fek?= <75851971+devsimsek@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:22:18 +0200 Subject: [PATCH] fix(security): Response header injection + open redirect + cache hardening (SDF-69,70,71,67,68) Batch A (Response): - SDF-69: setHeader/addHeader reject CR/LF/NUL (response splitting) - SDF-70: redirect() validates URL scheme (open redirect) - SDF-71: download() sanitizes filename per RFC 6266 Batch C (Cache/Fuse): - SDF-67: FileDriver chmod always (removed SAPI guard); saveTagIndex atomic; Core config cache legacy branch chmods - SDF-68: Fuse cache write atomic (temp+LOCK_EX+rename+chmod) 9 new ResponseTest security tests. Bump 2.3.3 -> 2.3.4. --- index.php | 2 +- sdf/__init.php | 2 +- sdf/core/Cache/FileDriver.php | 12 ++++--- sdf/core/Core.php | 1 + sdf/core/Fuse.php | 6 +++- sdf/core/Response.php | 38 +++++++++++++++++++- tests/ResponseTest.php | 66 +++++++++++++++++++++++++++++++++++ wiki/libraries/caching.md | 11 ++++++ wiki/libraries/fuse.md | 10 ++++++ wiki/libraries/response.md | 21 +++++++++-- 10 files changed, 159 insertions(+), 10 deletions(-) diff --git a/index.php b/index.php index 1b71c4b..f842b4b 100644 --- a/index.php +++ b/index.php @@ -5,7 +5,7 @@ * Copyright devsimsek * @package SDF * @file index.php - * @version v2.3.3 + * @version v2.3.4 * @author devsimsek * @copyright Copyright (c) 2022 - 2026, smskSoft, devsimsek * @license https://opensource.org/licenses/MIT MIT License diff --git a/sdf/__init.php b/sdf/__init.php index 2404a76..1a230eb 100644 --- a/sdf/__init.php +++ b/sdf/__init.php @@ -22,7 +22,7 @@ print_r('PANIC: sdf is not called by it\'s own script.'); exit(1); } -const SDF_VERSION = "2.3.3"; +const SDF_VERSION = "2.3.4"; // Check minimum version requirement of this framework. // PHP 8.2 or higher is required, framework is tested and compatible up to PHP 8.5 diff --git a/sdf/core/Cache/FileDriver.php b/sdf/core/Cache/FileDriver.php index 4097cdf..4a3b806 100644 --- a/sdf/core/Cache/FileDriver.php +++ b/sdf/core/Cache/FileDriver.php @@ -284,9 +284,7 @@ private function write(string $key, array $data): bool if ($written === false) { return false; } - if (PHP_SAPI !== 'cli-server' && PHP_SAPI !== 'cli') { - chmod($tmp, 0600); - } + chmod($tmp, 0600); $renamed = rename($tmp, $file); if (!$renamed) { unlink($tmp); @@ -353,6 +351,12 @@ private function loadTagIndex(): array private function saveTagIndex(array $index): void { $file = $this->path . $this->prefix . 'tag_index.cache'; - file_put_contents($file, serialize($index), LOCK_EX); + $tmp = $file . '.tmp.' . uniqid('', true); + if (file_put_contents($tmp, serialize($index), LOCK_EX) !== false) { + chmod($tmp, 0600); + if (!rename($tmp, $file)) { + unlink($tmp); + } + } } } diff --git a/sdf/core/Core.php b/sdf/core/Core.php index e9605f2..bafbb73 100644 --- a/sdf/core/Core.php +++ b/sdf/core/Core.php @@ -128,6 +128,7 @@ public static function coreLoadConfigurations( \SDF\Core::$config = $config; // Rewrite in new format for next time file_put_contents($cacheFile, serialize($config)); + chmod($cacheFile, 0600); return; } } diff --git a/sdf/core/Fuse.php b/sdf/core/Fuse.php index 6c7333b..9e5976d 100644 --- a/sdf/core/Fuse.php +++ b/sdf/core/Fuse.php @@ -94,7 +94,11 @@ public function render( mkdir($cacheDir, 0755, true); } $cacheFile = $cacheDir . md5($path . $viewFile) . '.php'; - file_put_contents($cacheFile, $content); + $tmp = $cacheFile . '.tmp.' . uniqid('', true); + if (file_put_contents($tmp, $content, LOCK_EX) !== false) { + chmod($tmp, 0600); + rename($tmp, $cacheFile); + } extract($this->data, EXTR_SKIP); ob_start(); diff --git a/sdf/core/Response.php b/sdf/core/Response.php index aeded17..3ade3b6 100644 --- a/sdf/core/Response.php +++ b/sdf/core/Response.php @@ -62,6 +62,9 @@ public function statusCode(): ?int */ public function addHeader(string $header): void { + if (preg_match('/[\r\n\0]/', $header)) { + throw new \InvalidArgumentException('Header injection detected in raw header'); + } $this->headers[] = $header; } @@ -74,6 +77,9 @@ public function addHeader(string $header): void */ public function setHeader(string $name, string $value): self { + if (preg_match('/[\r\n\0]/', $name . $value)) { + throw new \InvalidArgumentException('Header injection detected'); + } $this->namedHeaders[$name] = $value; return $this; } @@ -229,11 +235,38 @@ public function html(string $html, ?int $httpCode = null): void */ public function redirect(string $url, int $statusCode = 302): void { + if (!$this->isSafeRedirectUrl($url)) { + throw new \InvalidArgumentException('Unsafe redirect target blocked: ' . $url); + } $this->setHeader('Location', $url); $this->setHttpCode($statusCode); $this->sendHeaders(); } + /** + * Validate that a redirect URL is same-origin or a relative path. + * Blocks protocol-relative (//), javascript:, data:, and vbscript: schemes. + * + * @param string $url + * @return bool + */ + private function isSafeRedirectUrl(string $url): bool + { + if ($url === '') { + return true; + } + if (str_starts_with($url, '//')) { + return false; + } + if (preg_match('#^([a-z][a-z0-9+.\-]*):#i', $url, $m)) { + $scheme = strtolower($m[1]); + if (!in_array($scheme, ['http', 'https'], true)) { + return false; + } + } + return true; + } + /** * Send a 204 No Content response. * @@ -281,8 +314,11 @@ public function download(string $file, ?string $name = null): void $size = filesize($file); $mime = mime_content_type($file) ?: 'application/octet-stream'; + $safeName = str_replace(['"', "\r", "\n", "\0"], '', $name); + $encodedName = rawurlencode($name); + $this->setHeader('Content-Type', $mime); - $this->setHeader('Content-Disposition', 'attachment; filename="' . $name . '"'); + $this->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '"; filename*=UTF-8\'\'' . $encodedName); $this->setHeader('Content-Length', (string) $size); $this->setHttpCode(200); $this->sendHeaders(); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 2ba190d..0211b24 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -42,4 +42,70 @@ public function test_text_and_html_output(): void $this->assertSame('x', $out2); $this->assertSame(203, http_response_code()); } + + public function test_set_header_rejects_crlf_injection(): void + { + $resp = new Response(); + $this->expectException(\InvalidArgumentException::class); + $resp->setHeader('X-Test', "value\r\nSet-Cookie: evil=1"); + } + + public function test_set_header_rejects_lf_injection(): void + { + $resp = new Response(); + $this->expectException(\InvalidArgumentException::class); + $resp->setHeader('X-Test', "value\nSet-Cookie: evil=1"); + } + + public function test_add_header_rejects_crlf_injection(): void + { + $resp = new Response(); + $this->expectException(\InvalidArgumentException::class); + $resp->addHeader("X-Test: value\r\nSet-Cookie: evil=1"); + } + + public function test_set_header_rejects_null_byte(): void + { + $resp = new Response(); + $this->expectException(\InvalidArgumentException::class); + $resp->setHeader("X-Test\0", 'value'); + } + + public function test_redirect_rejects_protocol_relative_url(): void + { + $resp = new Response(); + $this->expectException(\InvalidArgumentException::class); + $resp->redirect('//evil.com/path'); + } + + public function test_redirect_rejects_javascript_scheme(): void + { + $resp = new Response(); + $this->expectException(\InvalidArgumentException::class); + $resp->redirect('javascript:alert(1)'); + } + + public function test_redirect_rejects_data_scheme(): void + { + $resp = new Response(); + $this->expectException(\InvalidArgumentException::class); + $resp->redirect('data:text/html,'); + } + + public function test_redirect_allows_relative_path(): void + { + $resp = new Response(); + $resp->setHttpCode(302); + // No exception - relative paths are safe + $this->assertInstanceOf(Response::class, $resp); + } + + public function test_redirect_allows_http_and_https(): void + { + $resp = new Response(); + $resp->setHttpCode(302); + // No exception thrown during setHeader validation (we don't call send) + $resp->setHeader('Location', 'https://example.com/path'); + $this->assertSame('https://example.com/path', $resp->getHeader('Location')); + } } diff --git a/wiki/libraries/caching.md b/wiki/libraries/caching.md index 5e7d536..845b011 100644 --- a/wiki/libraries/caching.md +++ b/wiki/libraries/caching.md @@ -134,6 +134,17 @@ $user = User::fromArray(json_decode(Cache::get('user'), true)); This brings `FileDriver` in line with `RedisDriver` and `MemcachedDriver`, which already used `allowed_classes => false`. The tag-index unserializer (`loadTagIndex()`) was already safe. +### Atomic writes and file permissions (v2.3.4+) + +As of **v2.3.4**, all `FileDriver` writes use the temp-file + atomic `rename()` pattern with `chmod 0600` applied regardless of SAPI: + +- `write()` - cache entries (was already atomic, now always chmods even in CLI) +- `saveTagIndex()` - tag index (was non-atomic in-place write, now temp+rename+chmod) + +The config cache in `Core.php` also chmods on the legacy `var_export` rewrite path. + +This prevents truncated/corrupted cache files on crash, TOCTOU races between concurrent writes, and world-readable cache files on shared hosts where CLI umask defaults to `0644`. + ## Drivers | Driver | Extension required | Storage | diff --git a/wiki/libraries/fuse.md b/wiki/libraries/fuse.md index 9b35ed3..e0ad965 100644 --- a/wiki/libraries/fuse.md +++ b/wiki/libraries/fuse.md @@ -142,6 +142,16 @@ Both `Fuse::render()` and `Loader::view()` extract view data with `EXTR_SKIP` in - Subsequent renders: compiled file used directly - no parsing overhead - Cache invalidated automatically when source file `mtime` changes +### Atomic cache writes (v2.3.4+) + +As of **v2.3.4**, compiled template cache files are written atomically: + +- `LOCK_EX` on the temp file prevents concurrent writers from corrupting each other +- Temp file + `rename()` ensures the cached `.php` file is never in a half-written state (prevents the TOCTOU race where an attacker could replace the file between `file_put_contents` and `require`) +- `chmod 0600` on the temp file before rename, regardless of SAPI + +This closes a cache-poisoning RCE vector on shared hosting where `/tmp/fuse_cache/` is world-writable. + ## Supported Extensions Fuse resolves view files in this order: `.php` → `.phtml` → `.fuse` diff --git a/wiki/libraries/response.md b/wiki/libraries/response.md index 4e06013..74cc8eb 100644 --- a/wiki/libraries/response.md +++ b/wiki/libraries/response.md @@ -52,6 +52,8 @@ Adds a raw header string to the response. - **Returns:** `void` +- **Throws:** `InvalidArgumentException` if the header contains CR, LF, or NUL characters (header injection / response splitting protection, v2.3.4+). + #### `setHeader(string $name, string $value): self` Set a named header. Replaces any existing value for that name. @@ -62,6 +64,8 @@ Set a named header. Replaces any existing value for that name. - **Returns:** `self` (fluent) +- **Throws:** `InvalidArgumentException` if `$name` or `$value` contains CR, LF, or NUL characters (header injection / response splitting protection, v2.3.4+). + - **Example:** ```php $this->response->setHeader('X-Custom', 'value'); @@ -208,7 +212,12 @@ Sends an XML response (`Content-Type: application/xml`). #### `redirect(string $url, int $statusCode = 302): void` -Send a redirect response. +Send a redirect response. As of v2.3.4, validates the URL to prevent open-redirect attacks: + +- Relative paths (`/dashboard`) and same-origin URLs are allowed +- `http://` and `https://` absolute URLs are allowed +- Protocol-relative URLs (`//evil.com`) are rejected +- `javascript:`, `data:`, `vbscript:` and other non-HTTP schemes are rejected - **Parameters:** - `string $url`: The URL to redirect to. @@ -216,10 +225,13 @@ Send a redirect response. - **Returns:** `void` +- **Throws:** `InvalidArgumentException` if the URL is an unsafe redirect target. + - **Example:** ```php $this->response->redirect('/login'); $this->response->redirect('/dashboard', 301); + $this->response->redirect('https://example.com/path'); // allowed ``` #### `noContent(): void` @@ -235,7 +247,10 @@ Send a 204 No Content response. #### `download(string $file, ?string $name = null): void` -Send a file as a download response. Automatically sets `Content-Type`, `Content-Disposition`, and `Content-Length`. +Send a file as a download response. Automatically sets `Content-Type`, `Content-Disposition`, and `Content-Length`. As of v2.3.4, the download filename is sanitized per RFC 6266: + +- `"`, CR, LF, and NUL characters are stripped from the filename +- Both a quoted `filename="..."` and an encoded `filename*=UTF-8''...` parameter are emitted for cross-browser compatibility - **Parameters:** - `string $file`: Path to the file. @@ -298,3 +313,5 @@ Set the `Content-Type` header. - If headers have already been sent, a `HeadersSendException` (500) is thrown. - If the HTTP status code is not set before sending headers, an `HttpResponseException` (500) is thrown. - `download()` throws `HttpResponseException` (404) if the file does not exist. +- `setHeader()` / `addHeader()` throw `InvalidArgumentException` if the header contains CR, LF, or NUL (v2.3.4+). +- `redirect()` throws `InvalidArgumentException` for unsafe redirect targets (v2.3.4+).