From 20ad426bb207638591bcdd656e9c7604530359e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 2 Oct 2026 19:34:04 +0200 Subject: [PATCH 1/2] fix(cache): isolate and authenticate persistent WordPress backends --- README.md | 22 +++- .../SymPressFrameworkExtension.php | 5 + src/ObjectCache/CacheAdapterFactory.php | 43 +++++-- src/ObjectCache/CacheConfigFactory.php | 22 +++- src/ObjectCache/ObjectCacheMarshaller.php | 49 ++++++++ tests/Cache/CacheClearerContainerTest.php | 7 ++ .../SymPressFrameworkExtensionTest.php | 11 +- .../NativeObjectCacheBackendSmokeTest.php | 27 +++- tests/Kernel/SymPressFrameworkBundleTest.php | 8 ++ tests/ObjectCache/CacheAdapterFactoryTest.php | 116 +++++++++++++++++- tests/ObjectCache/CacheConfigFactoryTest.php | 23 ++++ 11 files changed, 312 insertions(+), 21 deletions(-) create mode 100644 src/ObjectCache/ObjectCacheMarshaller.php diff --git a/README.md b/README.md index ae2efc0..eeab0f9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Require the package in a SymPress project: composer require sympress/framework-bundle ``` -Production projects must provide a strong `APP_SECRET`. The bundle passes `APP_SECRET` directly to Symfony's +Projects must provide a randomly generated `APP_SECRET` of at least 32 bytes. Missing or shorter +secrets fail FrameworkBundle compilation. The bundle passes `APP_SECRET` directly to Symfony's FrameworkBundle and does not fall back to predictable project paths. The bundle is discovered through Composer metadata: @@ -155,7 +156,19 @@ If a persistent backend cannot be initialized, the drop-in logs or warns about t WP-CLI cache flushes run directly in the current CLI process and never create temporary PHP endpoints in the web root. Redis and Memcached use native object-cache backends instead of Symfony internals for WordPress counter semantics. `add`, `replace`, `incr` and `decr` are mapped to backend-native atomic operations where the backend supports them; Redis counters use a Lua script so missing keys are not created and decrements clamp to zero like WordPress expects. Existing non-numeric counter values follow WordPress core semantics and are treated as zero. Flushes use versioned namespaces, so group/runtime invalidation does not depend on scanning or reflecting backend internals. The other Symfony-backed drivers keep best-effort semantics because PSR-6 does not expose cross-process compare-and-swap primitives. -Native Redis and Memcached payloads are signed when `SYMPRESS_CACHE_SECRET`, `APP_SECRET` or `AUTH_KEY` is available. Treat persistent cache backends and the kernel cache directory as trusted infrastructure; do not expose Redis, Memcached, filesystem cache, SQLite/PDO cache files or debug container dumps to untrusted writers. +All persistent WordPress drivers require `SYMPRESS_CACHE_SECRET` or `APP_SECRET` of at least 32 bytes; +`AUTH_KEY` is not a signing-secret fallback. Missing or short object-cache secrets disable persistence +and report the initialization failure before using request-local cache. Redis and Memcached retain +native counter operations; APCu, filesystem and SQLite/PDO authenticate serialized values with the +same signed codec before reconstructing objects. Treat persistent cache backends and the kernel +cache directory as trusted infrastructure; do not expose them to untrusted writers. + +WordPress cache prefixes include a hash of the project root and environment, including when +`SYMPRESS_CACHE_PREFIX` is set. Roots resolve from `SYMPRESS_PROJECT_DIR`, `APP_PROJECT_DIR`, +`ABSPATH`, `WP_CONTENT_DIR`, then the working directory. Set a stable project root before loading +the drop-in in custom layouts. Environment resolves from `APP_ENV`, `APP_RUNTIME_ENV`, +`WP_ENVIRONMENT_TYPE`, then `production`. The signing key is also bound to this scoped prefix. +This changes the cache identity on upgrade; existing cache records remain unused and refill normally. Operational notes: @@ -180,7 +193,10 @@ validate the HMAC before deserializing. The codec permits only `stdClass`, objects) are rejected rather than invoking application magic methods. Custom application cache objects need a reviewed codec extension or plain data arrays. -Filesystem cache defaults to the system temporary directory. Directories beneath +Filesystem and default SQLite cache paths use a private per-user temporary directory with a +separate project/environment namespace and mode 0700. APCu still requires trusted PHP-FPM pools: +the extension can deserialize PHP objects written directly through its API before userland code runs. +The SymPress marshaller writes signed strings only. Directories beneath `WP_CONTENT_DIR` or the declared HTTP document root are rejected; configure a private path outside every location served by your web server. Initialization failure diagnostics include the exception class and never the exception text, diff --git a/src/DependencyInjection/SymPressFrameworkExtension.php b/src/DependencyInjection/SymPressFrameworkExtension.php index ff04e18..caadba4 100644 --- a/src/DependencyInjection/SymPressFrameworkExtension.php +++ b/src/DependencyInjection/SymPressFrameworkExtension.php @@ -30,6 +30,11 @@ public function load(array $configs, ContainerBuilder $container): void if ($configuration !== null) { $config = (new Processor())->processConfiguration($configuration, $configs); + $secret = $config['secret'] ?? null; + $resolved = is_string($secret) ? $container->resolveEnvPlaceholders($secret, true) : null; + if (!is_string($resolved) || strlen($resolved) < 32) { + throw new \InvalidArgumentException('FrameworkBundle requires APP_SECRET or framework.secret of at least 32 bytes.'); + } $container->setParameter('framework.cache', $config['cache'] ?? []); } diff --git a/src/ObjectCache/CacheAdapterFactory.php b/src/ObjectCache/CacheAdapterFactory.php index d8c9ee3..3e8d822 100644 --- a/src/ObjectCache/CacheAdapterFactory.php +++ b/src/ObjectCache/CacheAdapterFactory.php @@ -33,6 +33,12 @@ public function createRuntime(): ObjectCacheBackendInterface private function createAdapter(CacheConfig $config): ObjectCacheBackendInterface { + if (in_array($config->driver, ['apcu', 'filesystem', 'redis', 'memcached', 'pdo', 'sqlite'], true)) { + if (!is_string($config->secret) || strlen($config->secret) < 32) { + throw new \RuntimeException('Persistent object cache requires a secret of at least 32 bytes.'); + } + } + return match ($config->driver) { 'apcu' => new ObjectCacheAdapter($this->createApcu($config)), 'filesystem' => new ObjectCacheAdapter($this->createFilesystem($config)), @@ -46,18 +52,18 @@ private function createAdapter(CacheConfig $config): ObjectCacheBackendInterface private function createApcu(CacheConfig $config): AdapterInterface { - if (!ApcuAdapter::isSupported()) { + if (!ApcuAdapter::isSupported() || !apcu_enabled()) { return new ArrayAdapter(); } - return new ApcuAdapter($config->prefix); + return new ApcuAdapter($config->prefix, marshaller: new ObjectCacheMarshaller($this->codec($config))); } private function createFilesystem(CacheConfig $config): AdapterInterface { $directory = $this->stringArg($config, 'directory') ?? $this->stringArg($config, 'path') - ?? $this->defaultCacheDirectory(); + ?? $this->defaultCacheDirectory($config); $content = defined('WP_CONTENT_DIR') ? $this->resolvedPath(WP_CONTENT_DIR) : null; $root = isset($_SERVER['DOCUMENT_ROOT']) && is_string($_SERVER['DOCUMENT_ROOT']) @@ -70,7 +76,7 @@ private function createFilesystem(CacheConfig $config): AdapterInterface } } - return new FilesystemAdapter($config->prefix, 0, $directory); + return new FilesystemAdapter($config->prefix, 0, $directory, new ObjectCacheMarshaller($this->codec($config))); } private function resolvedPath(string $path): string @@ -123,11 +129,11 @@ private function createPdo(CacheConfig $config): AdapterInterface $dsn = $config->dsn ?? $this->stringArg($config, 'dsn') ?? $this->sqliteDsnFromArgs($config) - ?? sprintf('sqlite:%s/sympress-object-cache.sqlite', $this->defaultCacheDirectory()); + ?? sprintf('sqlite:%s/sympress-object-cache.sqlite', $this->defaultCacheDirectory($config)); $options = $config->driverArgs['options'] ?? []; - return new PdoAdapter($dsn, $config->prefix, 0, is_array($options) ? $options : []); + return new PdoAdapter($dsn, $config->prefix, 0, is_array($options) ? $options : [], new ObjectCacheMarshaller($this->codec($config))); } private function stringArg(CacheConfig $config, string $key): ?string @@ -166,9 +172,24 @@ private function sqliteDsnFromArgs(CacheConfig $config): ?string return $path !== null ? 'sqlite:' . $path : null; } - private function defaultCacheDirectory(): string + private function defaultCacheDirectory(CacheConfig $config): string { - return sys_get_temp_dir() . '/sympress-cache'; + $user = function_exists('posix_geteuid') ? (string) posix_geteuid() : hash('sha256', get_current_user()); + $root = sys_get_temp_dir() . '/sympress-cache-' . $user; + $directory = $root . '/' . substr(hash('sha256', $config->prefix), 0, 24); + foreach ([$root, $directory] as $path) { + if (is_link($path)) { + throw new \RuntimeException('Refusing a symlinked object-cache directory.'); + } + if (!is_dir($path) && !mkdir($path, 0700, true) && !is_dir($path)) { + throw new \RuntimeException('Unable to create a private object-cache directory.'); + } + if ((fileperms($path) & 0077) !== 0 || (function_exists('posix_geteuid') && fileowner($path) !== posix_geteuid())) { + throw new \RuntimeException('The private object-cache directory is accessible to another user.'); + } + } + + return $directory; } private function reportBackendFailure(CacheConfig $config, \Throwable $exception): void @@ -190,7 +211,11 @@ private function reportBackendFailure(CacheConfig $config, \Throwable $exception private function codec(CacheConfig $config): ObjectCacheValueCodec { - return new ObjectCacheValueCodec($config->secret); + $secret = is_string($config->secret) + ? hash_hmac('sha256', 'sympress.object-cache:' . $config->prefix, $config->secret) + : null; + + return new ObjectCacheValueCodec($secret); } private function assertRedisConnection(object $connection): void diff --git a/src/ObjectCache/CacheConfigFactory.php b/src/ObjectCache/CacheConfigFactory.php index 74ba4fd..e4f37b8 100644 --- a/src/ObjectCache/CacheConfigFactory.php +++ b/src/ObjectCache/CacheConfigFactory.php @@ -21,13 +21,29 @@ public function create(): CacheConfig $this->stringValue('SYMPRESS_CACHE_DSN') ?? $this->dsnFromArgs($driverArgs), $this->boolValue('SYMPRESS_CACHE_IN_MEMORY') ?? true, $this->intValue('SYMPRESS_CACHE_PURGE_INTERVAL') ?? self::DEFAULT_PURGE_INTERVAL, - $this->stringValue('SYMPRESS_CACHE_PREFIX') ?? 'sympress.wp', + $this->scopedPrefix(), $this->stringValue('SYMPRESS_CACHE_SECRET') - ?? $this->stringValue('APP_SECRET') - ?? $this->stringValue('AUTH_KEY'), + ?? $this->stringValue('APP_SECRET'), ); } + private function scopedPrefix(): string + { + $project = $this->stringValue('SYMPRESS_PROJECT_DIR') + ?? $this->stringValue('APP_PROJECT_DIR') + ?? $this->stringValue('ABSPATH') + ?? $this->stringValue('WP_CONTENT_DIR') + ?? (getcwd() ?: __DIR__); + $project = realpath($project) ?: $project; + $environment = $this->stringValue('APP_ENV') + ?? $this->stringValue('APP_RUNTIME_ENV') + ?? $this->stringValue('WP_ENVIRONMENT_TYPE') + ?? 'production'; + + return ($this->stringValue('SYMPRESS_CACHE_PREFIX') ?? 'sympress.wp') + . '.' . substr(hash('sha256', $project . "\0" . $environment), 0, 24); + } + private function normalizeDriver(string $driver): string { $driver = trim($driver, "\\ \t\n\r\0\x0B"); diff --git a/src/ObjectCache/ObjectCacheMarshaller.php b/src/ObjectCache/ObjectCacheMarshaller.php new file mode 100644 index 0000000..07c1684 --- /dev/null +++ b/src/ObjectCache/ObjectCacheMarshaller.php @@ -0,0 +1,49 @@ + $values + * @param-out list $failed + * @return array + */ + public function marshall(array $values, ?array &$failed): array + { + $failed = []; + $encoded = []; + foreach ($values as $key => $value) { + try { + // The envelope authenticates integers too and distinguishes cached false + // from a rejected payload without executing application magic methods. + $encoded[$key] = $this->codec->encode([$value]); + } catch (\Throwable) { + $failed[] = $key; + } + } + + return $encoded; + } + + public function unmarshall(string $value): mixed + { + if (!str_starts_with($value, 'sympress-cache-v2:')) { + throw new \DomainException('Unsigned object-cache payload.'); + } + $decoded = $this->codec->decode($value); + if (!is_array($decoded) || array_keys($decoded) !== [0]) { + throw new \DomainException('Invalid authenticated object-cache payload.'); + } + + return $decoded[0]; + } +} diff --git a/tests/Cache/CacheClearerContainerTest.php b/tests/Cache/CacheClearerContainerTest.php index aa31478..ffdb8e3 100644 --- a/tests/Cache/CacheClearerContainerTest.php +++ b/tests/Cache/CacheClearerContainerTest.php @@ -29,6 +29,8 @@ public static function environments(): iterable #[DataProvider('environments')] public function testNativeClearersAndDebugWarmerSurviveRuntimeCompilationAndFullLint(bool $debug): void { + $previousSecret = $_ENV['APP_SECRET'] ?? null; + $_ENV['APP_SECRET'] = str_repeat('test-only-secret-', 3); $projectDir = dirname(__DIR__, 2); $cacheDir = sys_get_temp_dir() . '/sympress-clearer-' . bin2hex(random_bytes(8)); $kernel = new class ($projectDir, $debug, $cacheDir) extends AbstractKernel { @@ -107,6 +109,11 @@ public function getCacheDir(): string self::assertFalse($appPool->hasItem('probe')); } finally { (new Filesystem())->remove($cacheDir); + if ($previousSecret === null) { + unset($_ENV['APP_SECRET']); + } else { + $_ENV['APP_SECRET'] = $previousSecret; + } } } } diff --git a/tests/DependencyInjection/SymPressFrameworkExtensionTest.php b/tests/DependencyInjection/SymPressFrameworkExtensionTest.php index 027f704..2b6ce55 100644 --- a/tests/DependencyInjection/SymPressFrameworkExtensionTest.php +++ b/tests/DependencyInjection/SymPressFrameworkExtensionTest.php @@ -16,7 +16,7 @@ public function testLoadsSymfonyFrameworkBundleServicesBeyondCache(): void (new SymPressFrameworkExtension())->load([ [ - 'secret' => 'test-secret', + 'secret' => str_repeat('test-only-secret-', 3), ], ], $container); @@ -25,10 +25,17 @@ public function testLoadsSymfonyFrameworkBundleServicesBeyondCache(): void self::assertTrue($container->hasDefinition('http_kernel')); self::assertTrue($container->hasDefinition('controller_resolver')); self::assertTrue($container->hasDefinition('cache.app')); - self::assertSame('test-secret', $container->getParameter('kernel.secret')); + self::assertSame(str_repeat('test-only-secret-', 3), $container->getParameter('kernel.secret')); self::assertIsArray($container->getParameter('framework.cache')); } + public function testRejectsShortSecretWithoutDisclosingIt(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/at least 32 bytes/'); + (new SymPressFrameworkExtension())->load([['secret' => 'test-only-short']], $this->container()); + } + private function container(): ContainerBuilder { $container = new ContainerBuilder(); diff --git a/tests/Integration/NativeObjectCacheBackendSmokeTest.php b/tests/Integration/NativeObjectCacheBackendSmokeTest.php index 3bbe412..4663323 100644 --- a/tests/Integration/NativeObjectCacheBackendSmokeTest.php +++ b/tests/Integration/NativeObjectCacheBackendSmokeTest.php @@ -9,6 +9,8 @@ use SymPress\Framework\ObjectCache\NativeMemcachedObjectCacheAdapter; use SymPress\Framework\ObjectCache\NativeRedisObjectCacheAdapter; use SymPress\Framework\ObjectCache\ObjectCacheValueCodec; +use SymPress\Framework\ObjectCache\CacheAdapterFactory; +use SymPress\Framework\ObjectCache\CacheConfig; #[Group('live-cache')] final class NativeObjectCacheBackendSmokeTest extends TestCase @@ -24,7 +26,7 @@ public function testRedisProtocolAndAtomicOperations(): void { self::assertTrue(class_exists(\Redis::class), 'The redis PHP extension is required.'); $redis = new \Redis(); - $this->connect(fn (): bool => $redis->connect('127.0.0.1', 6379, 1.0)); + $this->connect(fn (): bool => $redis->connect('127.0.0.1', (int) (getenv('SYMPRESS_TEST_REDIS_PORT') ?: 6379), 1.0)); $cache = new NativeRedisObjectCacheAdapter($redis, 'sympress-smoke-' . bin2hex(random_bytes(6)), new ObjectCacheValueCodec('review-secret')); self::assertTrue($cache->set('object', (object) ['date' => new \DateTimeImmutable('2026-10-01')])); @@ -46,7 +48,7 @@ public function testMemcachedProtocolAndAtomicOperations(): void { self::assertTrue(class_exists(\Memcached::class), 'The memcached PHP extension is required.'); $memcached = new \Memcached(); - self::assertTrue($memcached->addServer('127.0.0.1', 11211)); + self::assertTrue($memcached->addServer('127.0.0.1', (int) (getenv('SYMPRESS_TEST_MEMCACHED_PORT') ?: 11211))); $this->connect(static fn (): bool => $memcached->getVersion() !== false); $cache = new NativeMemcachedObjectCacheAdapter( $memcached, @@ -69,6 +71,27 @@ public function testMemcachedProtocolAndAtomicOperations(): void $memcached->quit(); } + public function testFactoriesUseSignedNativeBackendsWithStrongSecrets(): void + { + foreach (['redis', 'memcached'] as $driver) { + $port = $driver === 'redis' + ? (int) (getenv('SYMPRESS_TEST_REDIS_PORT') ?: 6379) + : (int) (getenv('SYMPRESS_TEST_MEMCACHED_PORT') ?: 11211); + $config = new CacheConfig($driver, [], $driver . '://127.0.0.1:' . $port, false, 0, 'sympress-factory-smoke-' . bin2hex(random_bytes(6)), str_repeat('test-only-secret-', 3)); + $factory = new CacheAdapterFactory(); + $backend = $factory->createPersistent($config); + self::assertInstanceOf($driver === 'redis' ? NativeRedisObjectCacheAdapter::class : NativeMemcachedObjectCacheAdapter::class, $backend); + self::assertTrue($backend->set('value', (object) ['driver' => $driver])); + $found = false; + self::assertEquals((object) ['driver' => $driver], $factory->createPersistent($config)->get('value', $found)); + self::assertTrue($found); + self::assertTrue($backend->set('counter', 1)); + self::assertSame(3, $backend->incr('counter', 2)); + self::assertSame(0, $backend->decr('counter', 9)); + self::assertTrue($backend->clear()); + } + } + /** @param callable(): bool $probe */ private function connect(callable $probe): void { diff --git a/tests/Kernel/SymPressFrameworkBundleTest.php b/tests/Kernel/SymPressFrameworkBundleTest.php index 61ffaf3..b288273 100644 --- a/tests/Kernel/SymPressFrameworkBundleTest.php +++ b/tests/Kernel/SymPressFrameworkBundleTest.php @@ -14,15 +14,23 @@ final class SymPressFrameworkBundleTest extends TestCase { private string $cacheDir; + private mixed $previousSecret; protected function setUp(): void { $this->cacheDir = sprintf('%s/sympress-framework-bundle-%s', sys_get_temp_dir(), uniqid('', true)); + $this->previousSecret = $_ENV['APP_SECRET'] ?? null; + $_ENV['APP_SECRET'] = str_repeat('test-only-secret-', 3); } protected function tearDown(): void { (new Filesystem())->remove($this->cacheDir); + if ($this->previousSecret === null) { + unset($_ENV['APP_SECRET']); + } else { + $_ENV['APP_SECRET'] = $this->previousSecret; + } } public function testBuildsRuntimeContainerWithSymfonyFrameworkBundleServices(): void diff --git a/tests/ObjectCache/CacheAdapterFactoryTest.php b/tests/ObjectCache/CacheAdapterFactoryTest.php index 94f9913..ba78ec1 100644 --- a/tests/ObjectCache/CacheAdapterFactoryTest.php +++ b/tests/ObjectCache/CacheAdapterFactoryTest.php @@ -7,9 +7,110 @@ use PHPUnit\Framework\TestCase; use SymPress\Framework\ObjectCache\CacheAdapterFactory; use SymPress\Framework\ObjectCache\CacheConfig; +use SymPress\Framework\ObjectCache\CacheConfigFactory; +use Symfony\Component\Cache\Adapter\ApcuAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Adapter\PdoAdapter; +use Symfony\Component\Filesystem\Filesystem; final class CacheAdapterFactoryTest extends TestCase { + #[\PHPUnit\Framework\Attributes\DataProvider('persistentDrivers')] + public function testPersistentSymfonyAdaptersAuthenticateBeforeDeserialization(string $driver): void + { + if ($driver === 'apcu' && (!ApcuAdapter::isSupported() || !apcu_enabled())) { + self::markTestSkipped('APCu requires apc.enable_cli=1 for CLI tests.'); + } + $directory = sys_get_temp_dir() . '/sympress-signed-cache-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $prefix = 'test-' . bin2hex(random_bytes(6)); + $dsn = 'sqlite:' . $directory . '/cache.sqlite'; + $config = new CacheConfig($driver, ['directory' => $directory], $dsn, false, 0, $prefix, str_repeat('test-only-secret-', 3)); + $factory = new CacheAdapterFactory(); + $backend = $factory->createPersistent($config); + try { + foreach ([false, 17, ['key' => new \DateTimeImmutable('2026-10-02')]] as $value) { + self::assertTrue($backend->set('value', $value)); + $found = false; + self::assertEquals($value, $factory->createPersistent($config)->get('value', $found)); + self::assertTrue($found); + } + self::assertTrue($backend->set('counter', 4)); + self::assertSame(7, $backend->incr('counter', 3)); + self::assertSame(0, $backend->decr('counter', 9)); + + $unsigned = match ($driver) { + 'apcu' => new ApcuAdapter($prefix, marshaller: new \Symfony\Component\Cache\Marshaller\DefaultMarshaller(false)), + 'filesystem' => new FilesystemAdapter($prefix, 0, $directory), + default => new PdoAdapter($dsn, $prefix), + }; + $item = $unsigned->getItem('forged'); + $item->set(new AdapterWakeupProbe()); + self::assertTrue($unsigned->save($item)); + $found = true; + self::assertFalse($factory->createPersistent($config)->get('forged', $found)); + self::assertFalse($found); + self::assertSame(0, AdapterWakeupProbe::$woken); + } finally { + $backend->clear(); + (new Filesystem())->remove($directory); + } + } + + /** @return iterable */ + public static function persistentDrivers(): iterable + { + yield 'filesystem' => ['filesystem']; + yield 'sqlite' => ['sqlite']; + yield 'apcu' => ['apcu']; + } + + public function testPersistentBackendWithoutStrongSecretUsesRequestLocalCache(): void + { + $directory = sys_get_temp_dir() . '/sympress-weak-secret-' . bin2hex(random_bytes(6)); + $previousErrorLog = ini_set('error_log', '/dev/null'); + try { + foreach ([null, 'short'] as $secret) { + $backend = (new CacheAdapterFactory())->createPersistent(new CacheConfig('filesystem', ['directory' => $directory], null, false, 0, 'test', $secret)); + self::assertTrue($backend->set('key', 'request-local')); + self::assertSame('request-local', $backend->get('key')); + self::assertDirectoryDoesNotExist($directory); + } + } finally { + ini_set('error_log', is_string($previousErrorLog) ? $previousErrorLog : ''); + } + } + + #[\PHPUnit\Framework\Attributes\RunInSeparateProcess] + #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] + public function testDefaultFilesystemStorageIsPrivateAndSeparatedAcrossSites(): void + { + $_ENV['SYMPRESS_CACHE_DRIVER'] = 'filesystem'; + $_ENV['APP_SECRET'] = str_repeat('test-only-secret-', 3); + $_ENV['APP_PROJECT_DIR'] = '/sites/cache-test-' . bin2hex(random_bytes(6)); + $firstConfig = (new CacheConfigFactory())->create(); + $_ENV['APP_PROJECT_DIR'] .= '-second'; + $secondConfig = (new CacheConfigFactory())->create(); + $factory = new CacheAdapterFactory(); + $first = $factory->createPersistent($firstConfig); + $second = $factory->createPersistent($secondConfig); + $paths = []; + try { + foreach ([$first, $second] as $backend) { + $pool = (new \ReflectionProperty($backend, 'pool'))->getValue($backend); + self::assertInstanceOf(FilesystemAdapter::class, $pool); + $directory = (new \ReflectionProperty($pool, 'directory'))->getValue($pool); + $paths[] = dirname(rtrim($directory, '/')); + self::assertSame(0700, fileperms(end($paths)) & 0777); + } + self::assertNotSame($paths[0], $paths[1]); + self::assertTrue($first->set('site-value', 'first-site')); + self::assertFalse($second->get('site-value')); + } finally { + (new Filesystem())->remove($paths); + } + } + public function testRedisConnectionFailureFallsBackToArrayBackend(): void { $previousErrorLog = ini_set('error_log', '/dev/null'); @@ -22,6 +123,7 @@ public function testRedisConnectionFailureFallsBackToArrayBackend(): void true, 0, 'sympress.test', + str_repeat('test-only-secret-', 3), )); } finally { ini_set('error_log', is_string($previousErrorLog) ? $previousErrorLog : ''); @@ -44,10 +146,10 @@ public function testPublicFilesystemPathsFallBackWithoutWritingCacheFiles(): voi symlink($directory, $link); $previousErrorLog = ini_set('error_log', '/dev/null'); try { - $backend = (new CacheAdapterFactory())->createPersistent(new CacheConfig('filesystem', ['directory' => $directory . '/cache'], null, false, 0, 'test')); + $backend = (new CacheAdapterFactory())->createPersistent(new CacheConfig('filesystem', ['directory' => $directory . '/cache'], null, false, 0, 'test', str_repeat('test-only-secret-', 3))); self::assertTrue($backend->set('private', 'sensitive')); self::assertDirectoryDoesNotExist($directory . '/cache'); - $linked = (new CacheAdapterFactory())->createPersistent(new CacheConfig('filesystem', ['directory' => $link . '/new-cache'], null, false, 0, 'test')); + $linked = (new CacheAdapterFactory())->createPersistent(new CacheConfig('filesystem', ['directory' => $link . '/new-cache'], null, false, 0, 'test', str_repeat('test-only-secret-', 3))); self::assertTrue($linked->set('private', 'sensitive')); self::assertDirectoryDoesNotExist($directory . '/new-cache'); } finally { @@ -57,3 +159,13 @@ public function testPublicFilesystemPathsFallBackWithoutWritingCacheFiles(): voi } } } + +final class AdapterWakeupProbe +{ + public static int $woken = 0; + + public function __wakeup(): void + { + self::$woken++; + } +} diff --git a/tests/ObjectCache/CacheConfigFactoryTest.php b/tests/ObjectCache/CacheConfigFactoryTest.php index f35e59e..85cf8eb 100644 --- a/tests/ObjectCache/CacheConfigFactoryTest.php +++ b/tests/ObjectCache/CacheConfigFactoryTest.php @@ -22,6 +22,8 @@ protected function tearDown(): void 'SYMPRESS_CACHE_SECRET', 'APP_SECRET', 'AUTH_KEY', + 'APP_PROJECT_DIR', + 'APP_ENV', ] as $name ) { putenv($name); @@ -29,6 +31,27 @@ protected function tearDown(): void } } + public function testNamespaceSeparatesProjectsAndEnvironmentsEvenWithExplicitPrefix(): void + { + $_ENV['APP_PROJECT_DIR'] = '/sites/first'; + $_ENV['APP_ENV'] = 'production'; + $_ENV['SYMPRESS_CACHE_PREFIX'] = 'shared-prefix'; + $factory = new CacheConfigFactory(); + $first = $factory->create()->prefix; + self::assertSame($first, $factory->create()->prefix); + $_ENV['APP_PROJECT_DIR'] = '/sites/second'; + self::assertNotSame($first, $factory->create()->prefix); + $_ENV['APP_PROJECT_DIR'] = '/sites/first'; + $_ENV['APP_ENV'] = 'staging'; + self::assertNotSame($first, $factory->create()->prefix); + } + + public function testWordPressAuthKeyDoesNotEnablePersistentCacheSigning(): void + { + $_ENV['AUTH_KEY'] = str_repeat('test-only-auth-key-', 3); + self::assertNull((new CacheConfigFactory())->create()->secret); + } + public function testCreatesConfigFromSympressEnvironment(): void { putenv('SYMPRESS_CACHE_DRIVER=redis'); From 10b80e79ad266c8fd56c113d8199f54c9ff3c5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 2 Oct 2026 19:36:55 +0200 Subject: [PATCH 2/2] Document findings patch release --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bdbb8e..0c40511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) where applicable. +## 1.0.3 — 2026-10-02 + +- Scope WordPress object-cache namespaces and signing keys to the project and environment, including explicitly configured prefix labels. +- Authenticate filesystem, SQLite/PDO and APCu serialized payloads before decoding, matching the signed native Redis/Memcached boundary. +- Require a strong independent application/cache secret and isolate default temporary stores by user and namespace; weak configuration falls back safely to request memory. + ## Unreleased - Initial Framework Bundle package, QA workflow and repository documentation.