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+).