diff --git a/README.md b/README.md index e497731..ee21b0b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ composer require infocyph/cachelayer ```php use Infocyph\CacheLayer\Cache\Cache; -$cache = Cache::pdo('app'); // defaults to sqlite file in sys temp dir +$cache = Cache::pdo('app'); // defaults to sqlite file under sys temp cachelayer/pdo $cache->setTagged('user:1', ['name' => 'Ada'], ['users'], 300); @@ -68,6 +68,29 @@ $cache->invalidateTag('users'); $metrics = $cache->exportMetrics(); ``` +## Security Hardening + +CacheLayer includes optional payload/serialization hardening controls: + +```php +$cache + ->configurePayloadSecurity( + integrityKey: 'replace-with-strong-secret', + maxPayloadBytes: 8_388_608, + ) + ->configureSerializationSecurity( + allowClosurePayloads: false, + allowObjectPayloads: false, + ); +``` + +You can also set: + +- `CACHELAYER_PAYLOAD_INTEGRITY_KEY` +- `CACHELAYER_MAX_PAYLOAD_BYTES` + +See `SECURITY.md` for deployment guidance and threat model notes. + ## Documentation https://docs.infocyph.com/projects/CacheLayer diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9bea76c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,78 @@ +# Security Guide + +This document captures CacheLayer hardening guidance and rollout options. + +## Threat Model + +CacheLayer stores serialized payloads in backends that may be writable by local +or network-adjacent actors if infrastructure is misconfigured. Main risks: + +- Deserialization abuse when payloads are tampered. +- Executable cache-file abuse in `phpFiles` adapter. +- Insecure default temp-directory usage in shared environments. + +## Implemented Hardening + +### 1) Serialization and Payload Hardening + +- `CachePayloadCodec` supports signed payloads (HMAC-SHA256). +- Signed payloads are rejected when integrity verification fails. +- When an integrity key is configured, unsigned payloads are rejected. +- Maximum payload size can be enforced at decode time. +- `ValueSerializer` supports strict mode: + - block closure payloads + - block object payloads +- Native scalar/array serialization paths now decode with + `allowed_classes => false`. + +### Runtime API + +```php +$cache + ->configurePayloadSecurity( + integrityKey: 'replace-with-strong-secret', + maxPayloadBytes: 8_388_608, + ) + ->configureSerializationSecurity( + allowClosurePayloads: false, + allowObjectPayloads: false, + ); +``` + +### Environment Variables + +- `CACHELAYER_PAYLOAD_INTEGRITY_KEY` +- `CACHELAYER_MAX_PAYLOAD_BYTES` + +### 2) `phpFiles` Adapter Guardrails + +`phpFiles` keeps executable `.php` cache files for performance, so strict +directory controls are required. Runtime checks now reject: + +- symlinked cache directories +- world-writable cache directories + +Use `phpFiles` only on trusted hosts and private directories. + +### 3) Temp-Directory Hardening + +Default filesystem locations are now scoped under dedicated cachelayer temp +subdirectories: + +- file adapter default base: `sys_get_temp_dir()/cachelayer/files` +- php-files adapter default base: `sys_get_temp_dir()/cachelayer/phpfiles` +- PDO SQLite default: `sys_get_temp_dir()/cachelayer/pdo/cache_.sqlite` + +These paths are created with restrictive permissions and world-writable checks. + +## Recommended Production Profile + +1. Set `CACHELAYER_PAYLOAD_INTEGRITY_KEY` to a strong random secret. +2. Disable closure/object payloads unless explicitly required. +3. Use explicit, private cache directories outside shared temp space. +4. Prefer non-executable file storage adapters over `phpFiles` where possible. + +## Disclosure + +If you discover a security issue, please open a private report to project +maintainers before public disclosure. diff --git a/docs/adapters/file.rst b/docs/adapters/file.rst index ff97b47..44e09e7 100644 --- a/docs/adapters/file.rst +++ b/docs/adapters/file.rst @@ -10,7 +10,7 @@ Stores one cache payload per file under a namespace directory. Path layout: -* base dir: provided ``$dir`` or ``sys_get_temp_dir()`` +* base dir: provided ``$dir`` or ``sys_get_temp_dir() . '/cachelayer/files'`` * namespace dir: ``cache_`` * file name: ``hash('xxh128', $key) . '.cache'`` diff --git a/docs/adapters/pdo.rst b/docs/adapters/pdo.rst index 3f96790..90cc3fa 100644 --- a/docs/adapters/pdo.rst +++ b/docs/adapters/pdo.rst @@ -29,7 +29,7 @@ Examples: .. code-block:: php - // Default sqlite file under sys temp dir + // Default sqlite file under sys temp: /tmp/cachelayer/pdo/cache_.sqlite $cacheDefault = Cache::pdo('app'); // MySQL / MariaDB diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst index 060a3b8..c7c7f2f 100644 --- a/docs/adapters/php-files.rst +++ b/docs/adapters/php-files.rst @@ -10,7 +10,7 @@ Persists cache records as PHP files that return payload arrays. Path layout: -* base dir: provided ``$dir`` or ``sys_get_temp_dir()`` +* base dir: provided ``$dir`` or ``sys_get_temp_dir() . '/cachelayer/phpfiles'`` * namespace dir: ``phpcache_`` * file name: ``hash('xxh128', $key) . '.php'`` @@ -21,6 +21,8 @@ Highlights: * ``setNamespaceAndDirectory()`` supported Good for environments where opcode cache integration is desired. +Use only in trusted environments, since cache entries are stored as executable +PHP files. Example ------- diff --git a/docs/adapters/sqlite.rst b/docs/adapters/sqlite.rst index 0d7f7b3..6a06201 100644 --- a/docs/adapters/sqlite.rst +++ b/docs/adapters/sqlite.rst @@ -11,7 +11,7 @@ Factory: ``Cache::sqlite(string $namespace = 'default', ?string $file = null)`` Equivalent behavior: * ``Cache::sqlite($namespace, $file)`` forwards to ``Cache::pdo($namespace, 'sqlite:' . $file)`` -* when ``$file`` is ``null``, default path is ``sys_get_temp_dir() . "/cache_.sqlite"`` +* when ``$file`` is ``null``, default path is ``sys_get_temp_dir() . "/cachelayer/pdo/cache_.sqlite"`` Use ``Cache::pdo(...)`` directly if you want to switch to MySQL/MariaDB/PostgreSQL without changing the rest of your cache usage pattern. diff --git a/docs/cache.rst b/docs/cache.rst index 658007e..7b6a3be 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -217,6 +217,33 @@ Notes: * Requires ``gzencode``/``gzdecode`` functions. * Compression configuration is global (``CachePayloadCodec`` static state). +Payload and Serialization Security +---------------------------------- + +Methods: + +* ``configurePayloadSecurity(?string $integrityKey = null, ?int $maxPayloadBytes = 8388608): self`` +* ``configureSerializationSecurity(bool $allowClosurePayloads = true, bool $allowObjectPayloads = true): self`` + +Example: + +.. code-block:: php + + $cache + ->configurePayloadSecurity( + integrityKey: 'replace-with-strong-secret', + maxPayloadBytes: 8_388_608, + ) + ->configureSerializationSecurity( + allowClosurePayloads: false, + allowObjectPayloads: false, + ); + +Environment variables: + +* ``CACHELAYER_PAYLOAD_INTEGRITY_KEY`` +* ``CACHELAYER_MAX_PAYLOAD_BYTES`` + Convenience Features -------------------- diff --git a/docs/index.rst b/docs/index.rst index 4725b26..a03d14b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,6 +51,7 @@ Quick Start adapters/index cookbook metrics-and-locking + security serializer memoize functions diff --git a/docs/security.rst b/docs/security.rst new file mode 100644 index 0000000..9da3fd1 --- /dev/null +++ b/docs/security.rst @@ -0,0 +1,29 @@ +.. _security: + +======== +Security +======== + +CacheLayer includes optional hardening controls for payload integrity, +serialization policy, and filesystem defaults. + +Quick hardening setup: + +.. code-block:: php + + $cache + ->configurePayloadSecurity( + integrityKey: 'replace-with-strong-secret', + maxPayloadBytes: 8_388_608, + ) + ->configureSerializationSecurity( + allowClosurePayloads: false, + allowObjectPayloads: false, + ); + +Environment variables: + +* ``CACHELAYER_PAYLOAD_INTEGRITY_KEY`` +* ``CACHELAYER_MAX_PAYLOAD_BYTES`` + +For detailed policy and rollout guidance, see project root ``SECURITY.md``. diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index eae6c53..d288e82 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -14,8 +14,12 @@ final class CachePayloadCodec { private const string COMPRESSED_PREFIX = 'imx-gz:'; private const string FORMAT = 'imx-record-v1'; + private const string SIGNED_PREFIX = 'imx-sig-v1:'; private static int $compressionLevel = 6; private static ?int $compressionThresholdBytes = null; + private static ?string $integrityKey = null; + private static ?int $maxPayloadBytes = 8_388_608; + private static bool $securityBootstrapped = false; public static function configureCompression(?int $thresholdBytes = null, int $level = 6): void { @@ -23,12 +27,36 @@ public static function configureCompression(?int $thresholdBytes = null, int $le self::$compressionLevel = max(1, min(9, $level)); } + public static function configureSecurity( + ?string $integrityKey = null, + ?int $maxPayloadBytes = 8_388_608, + ): void { + self::$integrityKey = $integrityKey !== null && $integrityKey !== '' ? $integrityKey : null; + self::$maxPayloadBytes = $maxPayloadBytes === null ? null : max(1, $maxPayloadBytes); + self::$securityBootstrapped = true; + } + /** * @return array{value:mixed,expires:int|null}|null */ public static function decode(string $blob): ?array { - $decoded = self::tryUnserialize(self::expandIfCompressed($blob)); + self::bootstrapSecurityFromEnvironment(); + if (self::isPayloadTooLarge($blob)) { + return null; + } + + $verifiedBlob = self::verifyAndExtractSignature($blob); + if (!is_string($verifiedBlob)) { + return null; + } + + $expanded = self::expandIfCompressed($verifiedBlob); + if (self::isPayloadTooLarge($expanded)) { + return null; + } + + $decoded = self::tryUnserialize($expanded); if ($decoded === null) { return null; } @@ -43,6 +71,7 @@ public static function decode(string $blob): ?array public static function encode(mixed $value, ?int $expiresAt): string { + self::bootstrapSecurityFromEnvironment(); $encoded = ValueSerializer::serialize([ '__imx_cache' => self::FORMAT, 'value' => $value, @@ -50,19 +79,19 @@ public static function encode(mixed $value, ?int $expiresAt): string ]); if (self::$compressionThresholdBytes === null || self::$compressionThresholdBytes < 1) { - return $encoded; + return self::attachSignature($encoded); } if (strlen($encoded) < self::$compressionThresholdBytes || !function_exists('gzencode')) { - return $encoded; + return self::attachSignature($encoded); } $compressed = gzencode($encoded, self::$compressionLevel); if (!is_string($compressed) || strlen($compressed) >= strlen($encoded)) { - return $encoded; + return self::attachSignature($encoded); } - return self::COMPRESSED_PREFIX . base64_encode($compressed); + return self::attachSignature(self::COMPRESSED_PREFIX . base64_encode($compressed)); } /** @@ -86,6 +115,34 @@ public static function toDateTime(?int $expiresAt): ?DateTimeInterface return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); } + private static function attachSignature(string $payload): string + { + if (self::$integrityKey === null) { + return $payload; + } + + $signature = hash_hmac('sha256', $payload, self::$integrityKey); + return self::SIGNED_PREFIX . $signature . ':' . $payload; + } + + private static function bootstrapSecurityFromEnvironment(): void + { + if (self::$securityBootstrapped) { + return; + } + + $key = getenv('CACHELAYER_PAYLOAD_INTEGRITY_KEY'); + $max = getenv('CACHELAYER_MAX_PAYLOAD_BYTES'); + + $integrityKey = is_string($key) && $key !== '' ? $key : null; + $maxBytes = null; + if (is_string($max) && $max !== '' && ctype_digit($max)) { + $maxBytes = (int) $max; + } + + self::configureSecurity($integrityKey, $maxBytes ?? self::$maxPayloadBytes); + } + /** * @return array{value:mixed,expires:int|null}|null */ @@ -154,6 +211,11 @@ private static function expandIfCompressed(string $blob): string return is_string($decoded) ? $decoded : $blob; } + private static function isPayloadTooLarge(string $blob): bool + { + return self::$maxPayloadBytes !== null && strlen($blob) > self::$maxPayloadBytes; + } + private static function normalizeExpires(mixed $expires): ?int { return is_int($expires) ? $expires : null; @@ -167,4 +229,44 @@ private static function tryUnserialize(string $blob): mixed return null; } } + + private static function verifyAndExtractSignature(string $blob): ?string + { + if (!str_starts_with($blob, self::SIGNED_PREFIX)) { + return self::$integrityKey === null ? $blob : null; + } + + if (self::$integrityKey === null) { + return null; + } + + $prefixLength = strlen(self::SIGNED_PREFIX); + $rest = substr($blob, $prefixLength); + if ($rest === '') { + return null; + } + + $separatorPos = strpos($rest, ':'); + if ($separatorPos === false) { + return null; + } + + $signature = substr($rest, 0, $separatorPos); + $payload = substr($rest, $separatorPos + 1); + + if (strlen($signature) !== 64) { + return null; + } + + if (!ctype_xdigit($signature)) { + return null; + } + + $expected = hash_hmac('sha256', $payload, self::$integrityKey); + if (!hash_equals($expected, strtolower($signature))) { + return null; + } + + return $payload; + } } diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index aace694..430c45a 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -19,6 +19,7 @@ */ class FileCacheAdapter extends AbstractCacheAdapter { + private const string DEFAULT_BASE_DIR = 'cachelayer/files'; private string $dir; /** @@ -135,6 +136,23 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof FileCacheItem; } + private function assertPathNotSymlink(string $path, string $label): void + { + if (is_link($path)) { + throw new RuntimeException($label . " must not be a symlink: {$path}"); + } + } + + private function assertSecureDirectory(string $path, string $label): void + { + $this->assertPathNotSymlink($path, $label); + + $perms = fileperms($path); + if ($perms !== false && (($perms & 0x0002) === 0x0002)) { + throw new RuntimeException($label . " must not be world-writable: {$path}"); + } + } + private function assertWritableDirectory(string $path, string $message): void { if (!is_writable($path)) { @@ -144,44 +162,61 @@ private function assertWritableDirectory(string $path, string $message): void private function createDirectory(string $ns, ?string $baseDir): void { - $baseDir = rtrim($baseDir ?? sys_get_temp_dir(), DIRECTORY_SEPARATOR); + $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = sanitize_cache_ns($ns); $this->dir = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; if (is_dir($this->dir)) { $this->assertWritableDirectory($this->dir, "Cache directory '$this->dir' exists but is not writable"); + $this->assertSecureDirectory($this->dir, 'Cache directory'); return; } $this->ensureBaseDirectoryExists($baseDir); $this->ensureCacheDirectoryExists($this->dir); $this->assertWritableDirectory($this->dir, 'Cache directory ' . $this->dir . ' is not writable'); + $this->assertSecureDirectory($this->dir, 'Cache directory'); + } + + private function defaultBaseDirectory(): string + { + return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_BASE_DIR); } private function ensureBaseDirectoryExists(string $baseDir): void { + $this->assertPathNotSymlink($baseDir, 'Cache base directory'); + if (file_exists($baseDir) && !is_dir($baseDir)) { throw new RuntimeException( 'Cache base path ' . realpath($baseDir) . ' exists and is *not* a directory', ); } - if (!is_dir($baseDir) && !@mkdir($baseDir, 0770, true) && !is_dir($baseDir)) { + if (!is_dir($baseDir) && !@mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { $this->throwCreationError('Failed to create base directory ' . $baseDir); } + + $this->assertSecureDirectory($baseDir, 'Cache base directory'); } private function ensureCacheDirectoryExists(string $cacheDir): void { + $this->assertPathNotSymlink($cacheDir, 'Cache directory'); + if (file_exists($cacheDir) && !is_dir($cacheDir)) { throw new RuntimeException( realpath($cacheDir) . ' exists and is not a directory', ); } - if (!@mkdir($cacheDir, 0770, true) && !is_dir($cacheDir)) { + if (!@mkdir($cacheDir, 0700, true) && !is_dir($cacheDir)) { $this->throwCreationError('Failed to create cache directory ' . $cacheDir); } + + $this->assertSecureDirectory($cacheDir, 'Cache directory'); } private function fileFor(string $key): string diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index fa04e1c..b747d09 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -12,6 +12,7 @@ final class PdoCacheAdapter extends AbstractCacheAdapter { + private const string DEFAULT_SQLITE_DIR = 'cachelayer/pdo'; private readonly string $driver; private readonly string $ns; private readonly PDO $pdo; @@ -33,7 +34,7 @@ public function __construct( $this->table = $table; $resolvedDsn = $dsn; if ($pdo === null && $resolvedDsn === null) { - $resolvedDsn = 'sqlite:' . sys_get_temp_dir() . "/cache_{$this->ns}.sqlite"; + $resolvedDsn = 'sqlite:' . self::defaultSqliteFileForNamespace($this->ns); } $this->pdo = $pdo ?? new PDO((string) $resolvedDsn, $username, $password); @@ -44,6 +45,18 @@ public function __construct( $this->createSchemaIfMissing(); } + public static function defaultSqliteFileForNamespace(string $namespace): string + { + $ns = sanitize_cache_ns($namespace); + $baseDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_SQLITE_DIR); + + self::ensureSecureDirectory($baseDir, 0700); + + return $baseDir . DIRECTORY_SEPARATOR . "cache_{$ns}.sqlite"; + } + public function clear(): bool { $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey LIKE :prefix"); @@ -203,6 +216,30 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof GenericCacheItem; } + private static function ensureSecureDirectory(string $path, int $mode): void + { + if (is_link($path)) { + throw new RuntimeException("Refusing symlinked SQLite cache directory: {$path}"); + } + + if (!is_dir($path) && !@mkdir($path, $mode, true) && !is_dir($path)) { + throw new RuntimeException("Unable to create SQLite cache directory: {$path}"); + } + + if (!is_writable($path)) { + throw new RuntimeException("SQLite cache directory is not writable: {$path}"); + } + + if (is_link($path)) { + throw new RuntimeException("Refusing symlinked SQLite cache directory: {$path}"); + } + + $perms = fileperms($path); + if ($perms !== false && (($perms & 0x0002) === 0x0002)) { + throw new RuntimeException("SQLite cache directory must not be world-writable: {$path}"); + } + } + private function configureDriverDefaults(): void { if ($this->driver !== 'sqlite') { diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 34a50af..62b3e60 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -10,6 +10,7 @@ final class PhpFilesCacheAdapter extends AbstractCacheAdapter { + private const string DEFAULT_BASE_DIR = 'cachelayer/phpfiles'; private string $dir; public function __construct(string $namespace = 'default', ?string $baseDir = null) @@ -165,19 +166,53 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof GenericCacheItem; } + private function assertPathNotSymlink(string $path, string $label): void + { + if (is_link($path)) { + throw new RuntimeException($label . " must not be a symlink: {$path}"); + } + } + + private function assertSecureDirectory(string $path, string $label): void + { + $this->assertPathNotSymlink($path, $label); + + $perms = fileperms($path); + if ($perms !== false && (($perms & 0x0002) === 0x0002)) { + throw new RuntimeException($label . " must not be world-writable: {$path}"); + } + } + private function createDirectory(string $ns, ?string $baseDir): void { - $baseDir = rtrim($baseDir ?? sys_get_temp_dir(), DIRECTORY_SEPARATOR); + $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = sanitize_cache_ns($ns); $this->dir = $baseDir . DIRECTORY_SEPARATOR . 'phpcache_' . $ns . DIRECTORY_SEPARATOR; - if (!is_dir($this->dir) && !@mkdir($this->dir, 0770, true) && !is_dir($this->dir)) { + $this->assertPathNotSymlink($baseDir, 'PHP cache base directory'); + $this->assertPathNotSymlink($this->dir, 'PHP cache directory'); + + if (!is_dir($baseDir) && !@mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { + throw new RuntimeException("Unable to create PHP cache base directory: {$baseDir}"); + } + + if (!is_dir($this->dir) && !@mkdir($this->dir, 0700, true) && !is_dir($this->dir)) { throw new RuntimeException("Unable to create PHP cache directory: {$this->dir}"); } + $this->assertSecureDirectory($baseDir, 'PHP cache base directory'); if (!is_writable($this->dir)) { throw new RuntimeException("PHP cache directory is not writable: {$this->dir}"); } + + $this->assertSecureDirectory($this->dir, 'PHP cache directory'); + } + + private function defaultBaseDirectory(): string + { + return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_BASE_DIR); } private function fileFor(string $key): string diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index c261332..e8c8056 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -16,6 +16,7 @@ use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Serializer\ValueSerializer; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; @@ -330,11 +331,11 @@ public static function sharedMemory(string $namespace = 'default', int $segmentS * Static factory for SQLite-based cache. * * @param string $namespace Cache prefix. Will be suffixed to each key. - * @param string|null $file Path to SQLite file (or null → sys temp dir). + * @param string|null $file Path to SQLite file (or null → cachelayer temp subdirectory). */ public static function sqlite(string $namespace = 'default', ?string $file = null): self { - $dbPath = $file ?? (sys_get_temp_dir() . '/cache_' . sanitize_cache_ns($namespace) . '.sqlite'); + $dbPath = $file ?? Adapter\PdoCacheAdapter::defaultSqliteFileForNamespace($namespace); return self::pdo( namespace: $namespace, @@ -387,6 +388,24 @@ public function configurePayloadCompression(?int $thresholdBytes = null, int $le return $this; } + public function configurePayloadSecurity(?string $integrityKey = null, ?int $maxPayloadBytes = 8_388_608): self + { + Adapter\CachePayloadCodec::configureSecurity($integrityKey, $maxPayloadBytes); + return $this; + } + + public function configureSerializationSecurity( + bool $allowClosurePayloads = true, + bool $allowObjectPayloads = true, + ): self { + ValueSerializer::configureSecurity( + allowClosurePayloads: $allowClosurePayloads, + allowObjectPayloads: $allowObjectPayloads, + ); + + return $this; + } + /** * Returns the number of items in the cache. * diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php index eb8c0bd..c985008 100644 --- a/src/Cache/CacheInterface.php +++ b/src/Cache/CacheInterface.php @@ -31,6 +31,13 @@ public function clearCache(): bool; public function configurePayloadCompression(?int $thresholdBytes = null, int $level = 6): self; + public function configurePayloadSecurity(?string $integrityKey = null, ?int $maxPayloadBytes = 8_388_608): self; + + public function configureSerializationSecurity( + bool $allowClosurePayloads = true, + bool $allowObjectPayloads = true, + ): self; + /** * Returns metrics grouped by readable adapter name (for example ``file``, * ``pdo``, ``redis``) and metric name. diff --git a/src/Cache/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index 92af4d8..6260a95 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -47,7 +47,12 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle usleep($this->retrySleepMicros); } - $token = hash('xxh3', $key . '|' . uniqid('', true)); + $token = self::generateToken(); + if ($token === null) { + @flock($handle, LOCK_UN); + @fclose($handle); + return null; + } $activeLocks[$key] = true; return new LockHandle($key, $token, $handle); @@ -77,4 +82,13 @@ private static function &activeRegistry(): array static $registry = []; return $registry; } + + private static function generateToken(): ?string + { + try { + return bin2hex(random_bytes(16)); + } catch (\Throwable) { + return null; + } + } } diff --git a/src/Cache/Lock/MemcachedLockProvider.php b/src/Cache/Lock/MemcachedLockProvider.php index d4b8c5b..b4d5cbb 100644 --- a/src/Cache/Lock/MemcachedLockProvider.php +++ b/src/Cache/Lock/MemcachedLockProvider.php @@ -27,7 +27,10 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); $lockKey = $this->prefix . hash('xxh128', $key); - $token = hash('xxh128', uniqid($key, true)); + $token = self::generateToken(); + if ($token === null) { + return null; + } $ttlSeconds = max(1, (int) ceil($waitSeconds + 1.0)); do { @@ -58,4 +61,13 @@ public function release(?LockHandle $handle): void // Best effort unlock. } } + + private static function generateToken(): ?string + { + try { + return bin2hex(random_bytes(16)); + } catch (Throwable) { + return null; + } + } } diff --git a/src/Cache/Lock/PdoLockProvider.php b/src/Cache/Lock/PdoLockProvider.php index a3bea9a..8b04d1c 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -47,6 +47,15 @@ public function release(?LockHandle $handle): void }; } + private static function generateToken(): ?string + { + try { + return bin2hex(random_bytes(16)); + } catch (Throwable) { + return null; + } + } + private static function signedCrc32(string $value): int { $u = crc32($value); @@ -61,7 +70,10 @@ private function acquireMysql(string $key, float $waitSeconds): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); $lockKey = $this->prefix . hash('xxh128', $key); - $token = hash('xxh128', uniqid($key, true)); + $token = self::generateToken(); + if ($token === null) { + return null; + } do { try { @@ -88,7 +100,10 @@ private function acquirePgsql(string $key, float $waitSeconds): ?LockHandle $deadline = microtime(true) + max(0.0, $waitSeconds); $lockKey = $this->prefix . hash('xxh128', $key); $advisoryKey = self::signedCrc32($lockKey); - $token = hash('xxh128', uniqid($key, true)); + $token = self::generateToken(); + if ($token === null) { + return null; + } do { try { diff --git a/src/Cache/Lock/RedisLockProvider.php b/src/Cache/Lock/RedisLockProvider.php index 138b427..d02540e 100644 --- a/src/Cache/Lock/RedisLockProvider.php +++ b/src/Cache/Lock/RedisLockProvider.php @@ -27,7 +27,10 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); $lockKey = $this->prefix . hash('xxh128', $key); - $token = hash('xxh128', uniqid($key, true)); + $token = self::generateToken(); + if ($token === null) { + return null; + } $ttlMs = max(1_000, (int) ceil(($waitSeconds + 1.0) * 1000)); do { @@ -63,4 +66,13 @@ public function release(?LockHandle $handle): void // Best effort unlock. } } + + private static function generateToken(): ?string + { + try { + return bin2hex(random_bytes(16)); + } catch (Throwable) { + return null; + } + } } diff --git a/src/Serializer/ValueSerializer.php b/src/Serializer/ValueSerializer.php index eccfa5a..90f1a57 100644 --- a/src/Serializer/ValueSerializer.php +++ b/src/Serializer/ValueSerializer.php @@ -4,13 +4,17 @@ namespace Infocyph\CacheLayer\Serializer; +use Closure; use InvalidArgumentException; use function Opis\Closure\{serialize as oc_serialize, unserialize as oc_unserialize}; final class ValueSerializer { + private const array NATIVE_SERIALIZED_PREFIXES = ['N', 'b', 'i', 'd', 's', 'a']; private const int SERIALIZED_CLOSURE_MEMO_LIMIT = 2048; + private static bool $allowClosurePayloads = true; + private static bool $allowObjectPayloads = true; /** @var array */ private static array $resourceHandlers = []; @@ -30,6 +34,14 @@ public static function clearResourceHandlers(): void self::$serializedClosureMemo = []; } + public static function configureSecurity( + bool $allowClosurePayloads = true, + bool $allowObjectPayloads = true, + ): void { + self::$allowClosurePayloads = $allowClosurePayloads; + self::$allowObjectPayloads = $allowObjectPayloads; + } + /** * Decode a payload produced by {@see encode()}. * @@ -140,10 +152,14 @@ public static function registerResourceHandler( */ public static function serialize(mixed $value): string { - if (is_scalar($value) || $value === null) { - return serialize($value); + self::assertAllowedBySecurityPolicy($value); + + $wrapped = self::wrapRecursive($value); + if (!self::requiresOpisSerialization($wrapped)) { + return serialize($wrapped); } - return oc_serialize(self::wrapRecursive($value)); + + return oc_serialize($wrapped); } @@ -161,9 +177,22 @@ public static function serialize(mixed $value): string */ public static function unserialize(string $blob): mixed { - if (!ValueSerializer::isSerializedClosure($blob) && str_starts_with($blob, 's:')) { - return unserialize($blob, ['allowed_classes' => true]); + if (self::isNativeSerializedPayload($blob) && !self::containsOpisPayloadMarker($blob)) { + return self::unwrapRecursive(unserialize($blob, ['allowed_classes' => false])); + } + + if (self::isSerializedClosure($blob)) { + if (!self::$allowClosurePayloads) { + throw new InvalidArgumentException('Closure payload deserialization is disabled by security policy.'); + } + + return self::unwrapRecursive(oc_unserialize($blob)); } + + if (!self::$allowObjectPayloads) { + throw new InvalidArgumentException('Object payload deserialization is disabled by security policy.'); + } + return self::unwrapRecursive(oc_unserialize($blob)); } @@ -182,6 +211,22 @@ public static function unwrap(mixed $resource): mixed return self::unwrapRecursive($resource); } + public static function useCompatibilitySecurity(): void + { + self::configureSecurity( + allowClosurePayloads: true, + allowObjectPayloads: true, + ); + } + + public static function useStrictSecurity(): void + { + self::configureSecurity( + allowClosurePayloads: false, + allowObjectPayloads: false, + ); + } + /** * Wraps resources within a given value. @@ -199,6 +244,44 @@ public static function wrap(mixed $value): mixed return self::wrapRecursive($value); } + private static function assertAllowedBySecurityPolicy(mixed $value): void + { + if (!is_array($value)) { + self::assertAllowedScalarOrNode($value); + return; + } + + foreach ($value as $item) { + self::assertAllowedBySecurityPolicy($item); + } + } + + private static function assertAllowedScalarOrNode(mixed $value): void + { + if ($value instanceof Closure) { + if (!self::$allowClosurePayloads) { + throw new InvalidArgumentException('Closure payload serialization is disabled by security policy.'); + } + + return; + } + + if (is_object($value) && !self::$allowObjectPayloads) { + throw new InvalidArgumentException('Object payload serialization is disabled by security policy.'); + } + } + + private static function containsOpisPayloadMarker(string $blob): bool + { + return str_contains($blob, 'Opis\\Closure\\'); + } + + private static function isNativeSerializedPayload(string $blob): bool + { + $first = $blob[0] ?? ''; + return in_array($first, self::NATIVE_SERIALIZED_PREFIXES, true); + } + private static function rememberSerializedClosureMemo(string $key, bool $value): bool { if (!array_key_exists($key, self::$serializedClosureMemo) @@ -213,6 +296,25 @@ private static function rememberSerializedClosureMemo(string $key, bool $value): return $value; } + private static function requiresOpisSerialization(mixed $value): bool + { + if (is_object($value) || is_resource($value)) { + return true; + } + + if (!is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (self::requiresOpisSerialization($item)) { + return true; + } + } + + return false; + } + /** * Reverse {@see wrapRecursive} by recursively unwrapping values * that were wrapped by {@see wrapRecursive}. diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php new file mode 100644 index 0000000..4f99409 --- /dev/null +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -0,0 +1,38 @@ + 'v'], null); + expect(str_starts_with($blob, 'imx-sig-v1:'))->toBeTrue(); + + $decoded = CachePayloadCodec::decode($blob); + expect($decoded)->toBeArray() + ->and($decoded['value'])->toBe(['k' => 'v']); +}); + +test('payload codec rejects tampered signed payload', function () { + CachePayloadCodec::configureSecurity('secret-key-123', 8_388_608); + + $blob = CachePayloadCodec::encode('value', null); + $tampered = $blob . 'x'; + + expect(CachePayloadCodec::decode($tampered))->toBeNull(); +}); + +test('payload codec rejects unsigned payload when integrity key is configured', function () { + $unsigned = CachePayloadCodec::encode('legacy', null); + + CachePayloadCodec::configureSecurity('secret-key-123', 8_388_608); + expect(CachePayloadCodec::decode($unsigned))->toBeNull(); +}); diff --git a/tests/Cache/PdoCachePoolTest.php b/tests/Cache/PdoCachePoolTest.php index fcca69b..686ca27 100644 --- a/tests/Cache/PdoCachePoolTest.php +++ b/tests/Cache/PdoCachePoolTest.php @@ -1,6 +1,7 @@ get('x'))->toBe('X'); - $dbFile = sys_get_temp_dir() . '/cache_' . sanitize_cache_ns($namespace) . '.sqlite'; + $dbFile = PdoCacheAdapter::defaultSqliteFileForNamespace($namespace); $cache->clear(); @unlink($dbFile); }); diff --git a/tests/Serializer/ValueSerializerTest.php b/tests/Serializer/ValueSerializerTest.php index 20142b5..96a11d5 100644 --- a/tests/Serializer/ValueSerializerTest.php +++ b/tests/Serializer/ValueSerializerTest.php @@ -6,6 +6,7 @@ beforeEach(function () { ValueSerializer::clearResourceHandlers(); + ValueSerializer::useCompatibilitySecurity(); }); it('serialises and unserialises scalars and arrays', function () { @@ -71,4 +72,12 @@ expect(count($memo->getValue()))->toBeLessThanOrEqual(2048); }); +it('strict security mode blocks closure payloads', function () { + ValueSerializer::useStrictSecurity(); + + expect(fn () => ValueSerializer::serialize(fn () => 1)) + ->toThrow(InvalidArgumentException::class); + + ValueSerializer::useCompatibilitySecurity(); +});