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.1
* @version v2.3.3
* @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.1";
const SDF_VERSION = "2.3.3";

// 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
2 changes: 1 addition & 1 deletion sdf/core/Cache/FileDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ private function read(string $key): ?array
if ($content === false) {
return null;
}
$data = unserialize($content, ['allowed_classes' => true]);
$data = unserialize($content, ['allowed_classes' => false]);
if (!is_array($data)) {
return null;
}
Expand Down
26 changes: 25 additions & 1 deletion sdf/core/Storage/Drivers/LocalDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,31 @@ public function __construct(string $root, string $urlPrefix = '')

private function resolve(string $path): string
{
return $this->root . '/' . ltrim($path, '/');
if (str_contains($path, "\0")) {
throw new \InvalidArgumentException('Storage path contains null byte');
}

$full = $this->root . '/' . ltrim($path, '/');
$parts = explode('/', $full);
$normalized = [];
foreach ($parts as $part) {
if ($part === '' || $part === '.') {
continue;
}
if ($part === '..') {
array_pop($normalized);
continue;
}
$normalized[] = $part;
}
$normalizedPath = '/' . implode('/', $normalized);

$rootPath = rtrim($this->root, '/');
if (!str_starts_with($normalizedPath . '/', $rootPath . '/')) {
throw new \InvalidArgumentException('Path escapes storage root: ' . $path);
}

return $normalizedPath;
}

public function exists(string $path): bool
Expand Down
16 changes: 16 additions & 0 deletions tests/CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ public function test_file_driver_handles_serialized_types(): void
$this->assertSame($data, $driver->get('complex'));
}

public function test_file_driver_refuses_object_unserialization(): void
{
$driver = new FileDriver(['path' => $this->tmpDir, 'prefix' => 'test_']);

// Craft a serialized object payload that would trigger __wakeup on a
// vulnerable class. Simulate an attacker writing the cache file directly.
$payload = 'O:8:"stdClass":1:{s:3:"foo";s:3:"bar";}';
$file = $this->tmpDir . '/test_obj.cache';
file_put_contents($file, $payload);

// With allowed_classes=false, unserialize returns __PHP_Incomplete_Class
// which fails the is_array() check and returns null - no gadget chain fires.
$result = $driver->get('obj');
$this->assertNull($result);
}

// ─── FileDriver: Tagging ──────────────────────────────────────────────────

public function test_file_driver_tagging(): void
Expand Down
31 changes: 31 additions & 0 deletions tests/StorageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,37 @@ public function test_url_no_prefix(): void
$this->assertSame('file.jpg', $this->driver->url('file.jpg'));
}

public function test_path_traversal_in_get_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->driver->get('../../../etc/passwd');
}

public function test_path_traversal_in_put_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->driver->put('../../outside.txt', 'evil');
}

public function test_path_traversal_in_delete_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->driver->delete('../../../etc/passwd');
}

public function test_null_byte_in_path_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->driver->get("file\0.txt");
}

public function test_dotdot_inside_path_normalizes(): void
{
$this->driver->makeDirectory('sub');
$this->driver->put('sub/file.txt', 'inside');
$this->assertSame('inside', $this->driver->get('sub/../sub/file.txt'));
}

public function test_facade_put_and_exists(): void
{
$this->setUpStorageConfig();
Expand Down
22 changes: 22 additions & 0 deletions wiki/libraries/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ Cache::normalizeKey(''); // throws \InvalidArgumentException

> If you cache objects, note that `FileDriver` currently unserializes with `allowed_classes => true`. Keep your cache directory private (`app/cache/` rather than `/tmp`) and never cache content that could be tampered with by another tenant. A future release will switch the default to `allowed_classes => false` for defense in depth.

### Object unserialization safety (v2.3.3+)

As of **v2.3.3**, `FileDriver::read()` unserializes with `allowed_classes => false`. This means **cached objects are no longer resurrected into PHP class instances** - they become `__PHP_Incomplete_Class` instead, which is a safe no-op (no `__wakeup`/`__destruct` magic methods fire). This blocks object-injection RCE gadget chains (Monolog, Guzzle, etc.) even if an attacker can write to the cache directory.

**Breaking change for users caching objects:** if you previously did `Cache::set('user', $userObject)` and expected `Cache::get('user')` to return a `User` instance, you will now get `null` (because the incomplete class fails the `is_array()` check) or an `__PHP_Incomplete_Class` if you bypass the facade. **Migrate to caching arrays/scalars:**

```php
// Before (v2.3.2 and earlier)
Cache::set('user', $userObject);
$user = Cache::get('user'); // User instance

// After (v2.3.3+)
Cache::set('user', $userObject->toArray());
$user = Cache::get('user'); // array

// Or JSON-encode explicitly
Cache::set('user', json_encode($userObject));
$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.

## Drivers

| Driver | Extension required | Storage |
Expand Down
19 changes: 19 additions & 0 deletions wiki/libraries/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ $driver->makeDirectory('new/path');
$driver->deleteDirectory('old/path');
```

### Path traversal protection (v2.3.2+)

As of **v2.3.2**, every `LocalDriver` method runs its `$path` through a lexical normalizer before touching the filesystem. The normalizer:

- Rejects null bytes (`\0`) with `InvalidArgumentException`
- Resolves `.` and `..` segments purely lexically (no `realpath()` needed, so it works for files that don't exist yet)
- Asserts the resolved path stays inside the configured root

If a path would escape the root, an `InvalidArgumentException` is thrown:

```php
$driver->get('../../../etc/passwd'); // throws InvalidArgumentException
$driver->put('../../shell.php', '<?php ...'); // throws InvalidArgumentException
$driver->get("file\0.txt"); // throws InvalidArgumentException
$driver->get('sub/../sub/file.txt'); // OK - normalizes to 'sub/file.txt'
```

This applies to all methods: `get`, `put`, `stream`, `delete`, `exists`, `size`, `mimeType`, `files`, `allFiles`, `directories`, `copy`, `move`, `makeDirectory`, `deleteDirectory`.

## S3Driver

Requires `aws/aws-sdk-php` via Composer. Supports S3-compatible object stores (MinIO, DigitalOcean Spaces, etc.) via the `endpoint` config key.
Expand Down
Loading