diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d40ea..8ede3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Additional tests for large variable payloads with multiline text and legacy invalid-cache handling. +- Cache entries can now safely store `null` values, and cache session state is + reset between sessions. +- Security hardening guidance for the system temporary directory default. ## [0.2.0] - 2026-08-13 diff --git a/TODO.md b/TODO.md index e892388..b1179d4 100644 --- a/TODO.md +++ b/TODO.md @@ -39,8 +39,12 @@ Deferred improvements — implement when explicitly requested or when capacity a Convenience method to check cache existence without starting a session. - **[LOW] Expand test suite** - Edge cases: empty string IDs, very long IDs, concurrent write simulation, - unreadable cache directory, Unicode in cache IDs. + Edge cases: empty string IDs, very long IDs, unreadable cache directory, + Unicode in cache IDs. + +- **[MEDIUM] Make cache writes atomic and protect readers from partial files** + `LOCK_EX` does not prevent readers from observing a file while it is being + written. Add an atomic-write strategy and concurrent-read/write tests. - **[LOW] Publish to Packagist** Run `composer validate --strict`, submit to packagist.org. diff --git a/class/SimplePhpCache.php b/class/SimplePhpCache.php index e896df7..751a2ec 100644 --- a/class/SimplePhpCache.php +++ b/class/SimplePhpCache.php @@ -16,6 +16,9 @@ class SimplePhpCache /** The cached content. */ private static $cacheContent = null; + /** Whether the current cache session contains a cached value. */ + private static bool $hasCacheContent = false; + /** The cache base directory. */ public static $cacheBaseDir = null; @@ -42,15 +45,19 @@ public static function initHTMLCaching($id, $refresh = false) self::$startedCache = $id; self::$cacheContent = null; + self::$hasCacheContent = false; $cacheFile = self::getCacheDir() . "/" . self::getFilename($id); // Check if the cached file is older then the configured time - if(!$refresh && file_exists($cacheFile) && - (time() - filemtime($cacheFile)) < self::$maxCacheTime) - { - self::$cacheContent = file_get_contents($cacheFile); - return false; + if (!$refresh && file_exists($cacheFile) && + (time() - filemtime($cacheFile)) < self::$maxCacheTime) { + $content = file_get_contents($cacheFile); + if ($content !== false) { + self::$cacheContent = $content; + self::$hasCacheContent = true; + return false; + } } ob_start(); @@ -73,7 +80,7 @@ public static function finishHTMLCaching($id) throw new RuntimeException("Cache isn't started"); } - if (self::$cacheContent != null) + if (self::$hasCacheContent) { $content = self::$cacheContent; } @@ -86,6 +93,8 @@ public static function finishHTMLCaching($id) } self::$startedCache = null; + self::$cacheContent = null; + self::$hasCacheContent = false; return $content; } @@ -111,6 +120,8 @@ public static function initVarCaching($id, $refresh = false) } self::$startedCache = $id; + self::$cacheContent = null; + self::$hasCacheContent = false; $cacheFile = self::getCacheDir() . "/" . self::getFilename($id); @@ -120,9 +131,10 @@ public static function initVarCaching($id, $refresh = false) { $raw = file_get_contents($cacheFile); if ($raw !== false) { - $decoded = self::decodeVarCachePayload($raw); - if ($decoded !== null) { + [$isValid, $decoded] = self::decodeVarCachePayload($raw); + if ($isValid) { self::$cacheContent = $decoded; + self::$hasCacheContent = true; return false; } @@ -161,6 +173,7 @@ public static function setVarCaching($id, $data) } self::$cacheContent = $data; + self::$hasCacheContent = true; self::writeVarCachePayload($cacheFile, $data); } @@ -180,9 +193,12 @@ public static function finishVarCaching($id) throw new RuntimeException("Cache isn't started"); } + $content = self::$cacheContent; self::$startedCache = null; + self::$cacheContent = null; + self::$hasCacheContent = false; - return self::$cacheContent; + return $content; } @@ -249,20 +265,20 @@ private static function getFilename($id) * Decode cache payload from the JSON cache format. * * @param string $raw - * @return mixed|null + * @return array{0: bool, 1: mixed} Whether the payload is valid and its value. */ private static function decodeVarCachePayload($raw) { if (!str_starts_with($raw, self::VAR_CACHE_PREFIX)) { - return null; + return [false, null]; } $json = substr($raw, strlen(self::VAR_CACHE_PREFIX)); try { - return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + return [true, json_decode($json, true, 512, JSON_THROW_ON_ERROR)]; } catch (\JsonException $e) { - return null; + return [false, null]; } } @@ -342,4 +358,4 @@ private static function getCacheDir() return $dir; } -} \ No newline at end of file +} diff --git a/docs/security-notes.md b/docs/security-notes.md index 6c75109..757d9c8 100644 --- a/docs/security-notes.md +++ b/docs/security-notes.md @@ -10,12 +10,20 @@ They are treated as invalid, deleted, and handled as cache miss. **What this means for you:** cache arrays and scalar values only. Do not cache objects. -## Cache Directory Permissions (Low) +## Cache Directory Permissions (Medium) -The `.simplePhpCache` subdirectory is created with mode `0770`. Recommendations: +The `.simplePhpCache` subdirectory is created with mode `0770` when it does not +already exist. The default base directory is the system temporary directory. +On shared systems, another local user could create the cache subdirectory first +or otherwise control its contents. This can enable cache poisoning and, with +unsafe filesystem permissions, symlink attacks. -- Set `$cacheBaseDir` to a directory outside the web root. -- Ensure only the web server user has write access to that directory. +Recommendations: + +- Always set `$cacheBaseDir` in production to an application-specific directory + outside the web root; do not rely on the system temporary directory. +- Ensure the base directory and `.simplePhpCache` are owned and writable only by + the web server user, and do not allow untrusted users to create entries there. - Never expose the cache directory via a public URL. ## Cache Key / ID Guidance (Low) diff --git a/tests/SimplePhpCacheTest.php b/tests/SimplePhpCacheTest.php index 3529926..7e424b1 100644 --- a/tests/SimplePhpCacheTest.php +++ b/tests/SimplePhpCacheTest.php @@ -44,6 +44,7 @@ private function resetStaticState(): void $r = new ReflectionClass(SimplePhpCache::class); $r->getProperty('startedCache')->setValue(null, null); $r->getProperty('cacheContent')->setValue(null, null); + $r->getProperty('hasCacheContent')->setValue(null, false); } private function getCacheFilePathForId(string $id): string @@ -92,6 +93,31 @@ public function testVarCachingHitOnSecondCall(): void $this->assertEquals($data, $result); } + public function testVarCachingNullRoundTrip(): void + { + $id = 'var_null'; + + SimplePhpCache::initVarCaching($id); + SimplePhpCache::setVarCaching($id, null); + SimplePhpCache::finishVarCaching($id); + $this->resetStaticState(); + + $miss = SimplePhpCache::initVarCaching($id); + $this->assertFalse($miss); + $this->assertNull(SimplePhpCache::finishVarCaching($id)); + } + + public function testVarCachingMissDoesNotReturnContentFromPreviousSession(): void + { + SimplePhpCache::initVarCaching('first'); + SimplePhpCache::setVarCaching('first', 'previous value'); + SimplePhpCache::finishVarCaching('first'); + + $miss = SimplePhpCache::initVarCaching('second'); + $this->assertTrue($miss); + $this->assertNull(SimplePhpCache::finishVarCaching('second')); + } + public function testVarCachingLargePayloadAndMultilineTextRoundTrip(): void { $id = 'var_large_multiline'; @@ -220,6 +246,18 @@ public function testHtmlCachingReturnsCachedContent(): void $this->assertEquals('stored content', $output); } + public function testHtmlCachingReturnsEmptyCachedContent(): void + { + $id = 'html_empty'; + + SimplePhpCache::initHTMLCaching($id); + SimplePhpCache::finishHTMLCaching($id); + $this->resetStaticState(); + + $this->assertFalse(SimplePhpCache::initHTMLCaching($id)); + $this->assertSame('', SimplePhpCache::finishHTMLCaching($id)); + } + // --- clear / count --- public function testClearCacheById(): void