Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion sdf/__init.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions sdf/core/Cache/FileDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
}
1 change: 1 addition & 0 deletions sdf/core/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
6 changes: 5 additions & 1 deletion sdf/core/Fuse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 37 additions & 1 deletion sdf/core/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
Expand Down
66 changes: 66 additions & 0 deletions tests/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,70 @@ public function test_text_and_html_output(): void
$this->assertSame('<b>x</b>', $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,<script>alert(1)</script>');
}

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'));
}
}
11 changes: 11 additions & 0 deletions wiki/libraries/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 10 additions & 0 deletions wiki/libraries/fuse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
21 changes: 19 additions & 2 deletions wiki/libraries/response.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
Expand Down Expand Up @@ -208,18 +212,26 @@ 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.
- `int $statusCode`: HTTP status code (default 302).

- **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`
Expand All @@ -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.
Expand Down Expand Up @@ -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+).
Loading