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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:

Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/DependencyInjection/SymPressFrameworkExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? []);
}

Expand Down
43 changes: 34 additions & 9 deletions src/ObjectCache/CacheAdapterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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'])
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 19 additions & 3 deletions src/ObjectCache/CacheConfigFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
49 changes: 49 additions & 0 deletions src/ObjectCache/ObjectCacheMarshaller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace SymPress\Framework\ObjectCache;

use Symfony\Component\Cache\Marshaller\MarshallerInterface;

final readonly class ObjectCacheMarshaller implements MarshallerInterface
{
public function __construct(private ObjectCacheValueCodec $codec)
{
}

/**
* @param array<array-key, mixed> $values
* @param-out list<array-key> $failed
* @return array<array-key, string>
*/
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];
}
}
7 changes: 7 additions & 0 deletions tests/Cache/CacheClearerContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
}
}
11 changes: 9 additions & 2 deletions tests/DependencyInjection/SymPressFrameworkExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function testLoadsSymfonyFrameworkBundleServicesBeyondCache(): void

(new SymPressFrameworkExtension())->load([
[
'secret' => 'test-secret',
'secret' => str_repeat('test-only-secret-', 3),
],
], $container);

Expand All @@ -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();
Expand Down
27 changes: 25 additions & 2 deletions tests/Integration/NativeObjectCacheBackendSmokeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')]));
Expand All @@ -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,
Expand All @@ -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
{
Expand Down
8 changes: 8 additions & 0 deletions tests/Kernel/SymPressFrameworkBundleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading