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 .github/workflows/dependency-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ jobs:
permissions:
contents: read
issues: write
uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
uses: sympress/workflows/.github/workflows/dependency-canary.yml@78d083a9a0176cf0252318b0c69fc64938a727d6
with:
php_version: '8.5'
4 changes: 2 additions & 2 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ permissions:

jobs:
workflows:
uses: sympress/workflows/.github/workflows/lint-workflows.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
uses: sympress/workflows/.github/workflows/lint-workflows.yml@78d083a9a0176cf0252318b0c69fc64938a727d6

qa:
uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
uses: sympress/workflows/.github/workflows/sympress-qa.yml@78d083a9a0176cf0252318b0c69fc64938a727d6
with:
php_version: '8.5'
secrets:
Expand Down
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ 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.

## Unreleased

- Keep upgrades with missing or short APP_SECRET available on existing kernels: WordPress object caches and Symfony application pools use request memory until a strong signing secret is provided; report the production requirement through validation without per-request warning noise.
- Authenticate Symfony application pool payloads before deserialization across persistent adapters and configured marshallers; preserve trusted Symfony system/compiled-code caches and discard unsigned legacy entries as misses.
- Use a stable literal SYMPRESS_PROJECT_DIR for WordPress signing/namespaces and Symfony cache pool seeds while retaining active-release package, configuration and autoloader discovery.
- Allow explicitly reviewed plugin/WooCommerce object classes only after signed payload authentication.

## 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.
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,18 @@ Require the package in a SymPress project:
composer require sympress/framework-bundle
```

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.
Projects must provide a randomly generated `APP_SECRET` of at least 32 bytes for production.
An existing missing or shorter value remains usable during an upgrade: WordPress object caching,
`cache.app` and custom application pools use request memory until a strong secret is supplied.
This also works when updating the bundle with an existing Kernel 1.1.3 installation.
Runtime Doctor checks this production requirement before deployment. The bundle passes `APP_SECRET`
directly to Symfony's FrameworkBundle and does not derive a secret from project paths or `AUTH_KEY`.

Application pools authenticate serialized payloads before restoring values, including filesystem,
PDO/SQLite, APCu, Redis and Memcached adapters and configured custom marshallers. Existing unsigned
entries become cache misses and refill normally after the upgrade. Application pools configured with
PHP file or system adapters use signed filesystem storage instead. Symfony's own `cache.system`
retains its trusted compiled-code storage and remains available without an application secret.

The bundle is discovered through Composer metadata:

Expand Down Expand Up @@ -150,23 +159,27 @@ Projects without SymPress Runtime can explicitly call `DropInInstaller::install(
Managed SymPress drop-ins are only rewritten when their contents change and third-party drop-ins without the `sympress-framework-object-cache` marker are left untouched by the runtime installer.
SymPress Runtime remains the preferred owner in SymPress Runtime projects because it publishes the drop-in during Composer/project setup instead of an explicit application setup step.

The delegator resolves the Composer autoloader from `SYMPRESS_PROJECT_DIR`, `APP_PROJECT_DIR`, `WP_CONTENT_DIR`, `ABSPATH`, or nearby parent directories; `SYMPRESS_COMPOSER_AUTOLOAD` and `SYMPRESS_OBJECT_CACHE_FUNCTIONS` can override those paths for custom layouts.
The delegator resolves the Composer autoloader from `APP_PROJECT_DIR`, `WP_CONTENT_DIR`, `ABSPATH`, or nearby active release directories, then uses `SYMPRESS_PROJECT_DIR` as a compatibility fallback; `SYMPRESS_COMPOSER_AUTOLOAD` and `SYMPRESS_OBJECT_CACHE_FUNCTIONS` can override those paths for custom layouts.
This makes the same file work when copied by SymPress Runtime, symlinked from `content-dev`, or installed by the runtime fallback.
If a persistent backend cannot be initialized, the drop-in logs or warns about the backend failure before falling back to request-local array cache.
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.

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
`AUTH_KEY` is not a signing-secret fallback. Missing or short object-cache secrets use request-local
cache without repeating a warning on every request; production validation reports the missing
requirement. 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`,
WordPress cache prefixes include a hash of the project identity and environment, including when
`SYMPRESS_CACHE_PREFIX` is set. Identity resolves from the literal `SYMPRESS_PROJECT_DIR`, then
`APP_PROJECT_DIR`, `ABSPATH`, `WP_CONTENT_DIR`, then the working directory. Set `SYMPRESS_PROJECT_DIR`
to a persistent deployment base or project identifier before the kernel/drop-in boots and retain it
across release directories. Symfony's default `framework.cache.prefix_seed` uses the same identity,
falling back to `kernel.project_dir` when it is absent. Package/configuration discovery and the
kernel's actual project directory still use the active release. 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.

Expand All @@ -191,7 +204,11 @@ validate the HMAC before deserializing. The codec permits only `stdClass`,
`WP_Post`, `WP_Term`, `WP_Comment`, `WP_User`, `WP_Error`, `WP_Site`, `WP_Network`, `DateTime`,
`DateTimeImmutable`, and `DateTimeZone`. Unsupported objects (including nested
objects) are rejected rather than invoking application magic methods. Custom
application cache objects need a reviewed codec extension or plain data arrays.
application cache objects can be admitted with `SYMPRESS_CACHE_ALLOWED_CLASSES`, a comma-separated
list of exact class names such as `WC_Product,Vendor\\Plugin\\CacheValue`. Wildcards and permissive
boolean values are rejected. Additional classes apply only to authenticated signed payloads;
review their deserialization/magic methods before adding them. Classes may load later during
WordPress/plugin bootstrap. Prefer plain data arrays when object restoration is unnecessary.

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:
Expand Down
2 changes: 1 addition & 1 deletion Resources/config/packages/framework.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@

return static function (ContainerConfigurator $container): void {
$container->extension('framework', [
'secret' => '%env(APP_SECRET)%',
'secret' => '%env(default::APP_SECRET)%',
]);
};
8 changes: 5 additions & 3 deletions Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use SymPress\Framework\Cache\CachePoolClearer;
use SymPress\Framework\Cache\CachePoolPruner;
use SymPress\Framework\Cache\CachePoolRegistry;
use SymPress\Framework\Cache\SignedMarshaller;
use SymPress\Framework\Cache\Command\CachePoolClearCommand;
use SymPress\Framework\Cache\Command\CachePoolDeleteCommand;
use SymPress\Framework\Cache\Command\CachePoolInvalidateTagsCommand;
Expand Down Expand Up @@ -56,9 +57,10 @@

return static function (ContainerConfigurator $container): void {
$parameters = $container->parameters();
$parameters->set('cache.prefix.seed', 'sympress.%kernel.project_dir%.%kernel.environment%');
$parameters->set('cache.prefix.seed', 'sympress.%env(default:kernel.project_dir:SYMPRESS_PROJECT_DIR)%.%kernel.environment%');
$parameters->set('framework.cache.version', '%kernel.environment%');
$parameters->set('framework.cache', []);
$parameters->set('framework.cache.secret', '%env(default::APP_SECRET)%');

$services = $container->services();
$services->defaults()
Expand Down Expand Up @@ -267,8 +269,8 @@
])
->tag('monolog.logger', ['channel' => 'cache']);

$services->set('cache.default_marshaller', DefaultMarshaller::class)
->args([null, '%kernel.debug%']);
$services->set('cache.default_marshaller', SignedMarshaller::class)
->args(['%framework.cache.secret%', '%cache.prefix.seed%', inline_service(DefaultMarshaller::class)->args([null, '%kernel.debug%'])]);

$services->set('cache.early_expiration_handler', EarlyExpirationHandler::class)
->args([service('reverse_container')])
Expand Down
3 changes: 2 additions & 1 deletion dropin/object-cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
$projectDirs[] = $dir;
};

$addProjectDir($path($value('SYMPRESS_PROJECT_DIR')));
$addProjectDir($path($value('APP_PROJECT_DIR')));

$contentDir = defined('WP_CONTENT_DIR') ? $path(constant('WP_CONTENT_DIR')) : $path(__DIR__);
Expand Down Expand Up @@ -79,6 +78,8 @@
$scanDir = $parent;
}

// Stable cache identity must not select a stale deployment-base autoloader.
$addProjectDir($path($value('SYMPRESS_PROJECT_DIR')));
$projectDirs = array_values(array_unique($projectDirs));
$autoload = $file($path($value('SYMPRESS_COMPOSER_AUTOLOAD')));

Expand Down
15 changes: 13 additions & 2 deletions src/Cache/FrameworkCacheConfigurationPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ final class FrameworkCacheConfigurationPass implements CompilerPassInterface
* @var array<string, mixed>
*/
private const array DEFAULTS = [
'prefix_seed' => 'sympress.%kernel.project_dir%.%kernel.environment%',
'prefix_seed' => 'sympress.%env(default:kernel.project_dir:SYMPRESS_PROJECT_DIR)%.%kernel.environment%',
'app' => 'cache.adapter.filesystem',
'system' => 'cache.adapter.system',
'directory' => '%kernel.cache_dir%/pools/app',
Expand Down Expand Up @@ -82,10 +82,21 @@ public function process(ContainerBuilder $container): void
}

$pools[$name] = $this->normalizePool($pool);
// App values use authenticated data stores; PHP bytecode caches remain a system-only surface.
foreach ($pools[$name]['adapters'] as $provider => $adapter) {
if (in_array($adapter, ['cache.adapter.php_files', 'cache.adapter.system', 'cache.system'], true)) {
$pools[$name]['adapters'][$provider] = 'cache.adapter.filesystem';
}
}
if (isset($pools[$name]['marshaller']) && $pools[$name]['marshaller'] !== 'cache.default_marshaller') {
$marshaller = '.sympress.signed_marshaller.' . $name;
$container->register($marshaller, SignedMarshaller::class)->setArguments(['%framework.cache.secret%', '%cache.prefix.seed%', new Reference($pools[$name]['marshaller'])]);
$pools[$name]['marshaller'] = $marshaller;
}
}

$pools['cache.app'] = $this->normalizePool([
'adapters' => [$config['app']],
'adapters' => [in_array($config['app'], ['cache.adapter.php_files', 'cache.adapter.system', 'cache.system'], true) ? 'cache.adapter.filesystem' : $config['app']],
'public' => true,
'tags' => false,
'clearer' => 'cache.app_clearer',
Expand Down
35 changes: 35 additions & 0 deletions src/Cache/GuardApplicationCachePoolsPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace SymPress\Framework\Cache;

use Symfony\Component\Cache\Adapter\AbstractAdapter;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

final class GuardApplicationCachePoolsPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasParameter('framework.cache.secret')) {
return;
}
foreach ($container->findTaggedServiceIds('cache.pool') as $id => $tags) {
$definition = $container->getDefinition($id);
// Symfony's system PHP caches are trusted compiled application code, not serialized app payloads.
if ($definition->isAbstract() || $definition->getFactory() === [AbstractAdapter::class, 'createSystemCache']) {
continue;
}
$inner = '.sympress.persistent.' . $id;
$persistent = clone $definition;
$persistent->setPublic(false)->setTags([]);
$container->setDefinition($inner, $persistent);
$definition->setFactory([GuardedCachePoolFactory::class, 'create']);
$definition->setArguments(['%framework.cache.secret%', new ServiceClosureArgument(new Reference($inner))]);
$definition->setMethodCalls([]);
}
}
}
18 changes: 18 additions & 0 deletions src/Cache/GuardedCachePoolFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace SymPress\Framework\Cache;

use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;

final class GuardedCachePoolFactory
{
/** @param \Closure(): AdapterInterface $persistent */
public static function create(?string $secret, \Closure $persistent): AdapterInterface
{
return $secret === null || strlen($secret) < 32 ? new TagAwareAdapter(new ArrayAdapter()) : $persistent();
}
}
47 changes: 47 additions & 0 deletions src/Cache/SignedMarshaller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace SymPress\Framework\Cache;

use Symfony\Component\Cache\Exception\CacheException;
use Symfony\Component\Cache\Marshaller\MarshallerInterface;

final readonly class SignedMarshaller implements MarshallerInterface
{
private const string PREFIX = 'sympress-symfony-cache-v1:';
private string $key;

public function __construct(?string $secret, string $namespace, private MarshallerInterface $inner)
{
if ($secret === null || strlen($secret) < 32) {
throw new CacheException('Persistent application caches require a strong application secret.');
}
$this->key = hash_hmac('sha256', 'sympress.symfony-cache:' . $namespace, $secret);
}

/** @param array<array-key, mixed> $values @param array<array-key>|null $failed @return array<array-key, string> */
public function marshall(array $values, ?array &$failed): array
{
$encoded = $this->inner->marshall($values, $failed);
foreach ($encoded as $id => $payload) {
$encoded[$id] = self::PREFIX . hash_hmac('sha256', $payload, $this->key) . ':' . base64_encode($payload);
}
return $encoded;
}

public function unmarshall(string $value): mixed
{
if (!str_starts_with($value, self::PREFIX)) {
throw new CacheException('Unsigned application cache payload.');
}
$payload = substr($value, strlen(self::PREFIX));
$signature = substr($payload, 0, 64);
$encoded = substr($payload, 65);
$decoded = base64_decode($encoded, true);
if (($payload[64] ?? null) !== ':' || !is_string($decoded) || !hash_equals(hash_hmac('sha256', $decoded, $this->key), $signature)) {
throw new CacheException('Application cache authentication failed.');
}
return $this->inner->unmarshall($decoded);
}
}
6 changes: 1 addition & 5 deletions src/DependencyInjection/SymPressFrameworkExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +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'] ?? []);
}

parent::load($configs, $container);
$container->setParameter('framework.cache.secret', $container->hasParameter('kernel.secret') ? $container->getParameter('kernel.secret') : '%env(default::APP_SECRET)%');

$this->removeUnavailableAutoconfiguration($container);
$this->removeSymfonyKernelConsoleCommands($container);
Expand Down
5 changes: 4 additions & 1 deletion src/ObjectCache/CacheAdapterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ final class CacheAdapterFactory
{
public function createPersistent(CacheConfig $config): ObjectCacheBackendInterface
{
if (in_array($config->driver, ['apcu', 'filesystem', 'redis', 'memcached', 'pdo', 'sqlite'], true) && (!is_string($config->secret) || strlen($config->secret) < 32)) {
return $this->createRuntime();
}
try {
return $this->createAdapter($config);
} catch (\Throwable $exception) {
Expand Down Expand Up @@ -215,7 +218,7 @@ private function codec(CacheConfig $config): ObjectCacheValueCodec
? hash_hmac('sha256', 'sympress.object-cache:' . $config->prefix, $config->secret)
: null;

return new ObjectCacheValueCodec($secret);
return new ObjectCacheValueCodec($secret, $config->allowedClasses);
}

private function assertRedisConnection(object $connection): void
Expand Down
2 changes: 2 additions & 0 deletions src/ObjectCache/CacheConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class CacheConfig
{
/**
* @param array<string, mixed> $driverArgs
* @param list<string> $allowedClasses
*/
public function __construct(
public readonly string $driver,
Expand All @@ -17,6 +18,7 @@ public function __construct(
public readonly int $purgeInterval,
public readonly string $prefix,
public readonly ?string $secret = null,
public readonly array $allowedClasses = [],
) {
}
}
Loading
Loading