Commit d9830cca117 for woocommerce
commit d9830cca117c0d6078feed1ea511bd3578df36e7
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Jul 31 11:41:27 2026 +0300
Prevent crashes from invalid version cache values (#67164)
* fix: handle invalid version string cache values
The version string generator assumed every object-cache hit was a string. Mixed values returned by cache backends could cross the typed storage boundary and throw a TypeError, while a stored boolean false could remain cached.
Validate values at the cache-read boundary, delete stored non-string values, and preserve the existing generation contract. Use the cache found flag only to distinguish a stored false so valid strings from imperfect backends remain accepted.
Refs WOO6-44
* chore: add changelog for version cache fix
Record the invalid object-cache value reliability fix as a patch-level WooCommerce change so it is included in release notes.
Refs WOO6-44
* fix: reject empty cached version strings
Version tokens are UUIDs, but an empty cached string passed type validation and was refreshed. REST cache consumers treat empty values as absent, so entity invalidation could remain inert until the response TTL expires.
Treat empty strings as invalid cache entries and route them through the existing cleanup and generation behavior. Add coverage for empty values, genuine misses that must not delete, and valid strings returned by backends that do not set the found flag.
Refs WOO6-44
* test: cover invalid cache cleanup without found flag
Cache backends may return an invalid non-false value without populating the found argument. The cleanup predicate intentionally treats the value itself as evidence, but no test pinned that behavior.
Add a mock-backed characterization that expects deletion and a null miss result when generation is disabled. This kills the branch-specific mutation that weakened the cleanup condition.
Refs WOO6-44
* fix: detect cache entries that drop-ins report unconventionally
The cleanup predicate read `false !== $version || true === $found` to decide
whether an invalid cached value was actually stored. Both operands come from a
pluggable object-cache drop-in, which replaces wp_cache_get() wholesale, so
neither matches WP core's convention reliably.
A drop-in signalling a miss with null made `false !== null` true, firing a
delete on every genuine miss; get_version() runs once per tracked entity in the
REST API cache trait, so a cold-cache list request issued one wasted round-trip
per item against a remote backend. In the other direction, a drop-in reporting
the found flag as a truthy non-boolean failed the strict `true === $found`
check, so a stored false was left in place instead of being cleaned up.
Extract the decision into cache_entry_exists(), which treats the value as the
primary evidence, ignores null alongside false as miss-like, and checks the
found flag loosely as a tie-breaker. store_version() asked the same question
with its own predicate, so point it at the helper too and stop the two from
drifting apart.
Refs WOO6-44
* perf: skip the redundant cache delete when regenerating a version
Discarding an invalid cached value deleted the entry before generating a
replacement. The replacement is stored under the same deterministic cache key,
so the delete removed a value that the following wp_cache_set() overwrites
regardless.
Every in-tree caller reaches get_version() with $generate left at its default,
and the version string cache is only active when an external object cache is
configured, so this doubled the write round-trips against a remote backend on
every cold read. The REST API cache trait calls get_version() once per tracked
entity, so a cold-cache list request paid that cost per item.
Return early on the generate path and keep the cleanup for the case that
needs it: $generate set to false, where nothing else will overwrite the invalid
value. If the subsequent set fails the invalid value now survives where it was
previously deleted, but both leave the next read treating the entry as a miss,
so behaviour is unchanged either way.
Document the delete on the method, since a call with $generate set to false is
no longer read-only.
Refs WOO6-44
* feat: log discarded invalid version string cache entries
Invalid values read from the version string cache were discarded and replaced
silently. A backend or a third party corrupting these entries at scale left no
trace beyond generic cache thrash, so nobody on call could tell the difference
between a healthy store and one whose version strings never survive a write.
Log a warning naming the ID and the type of the unexpected value, matching the
diagnosability the HPOS order and meta cache reads gained in #66131. The log is
gated on cache_entry_exists(), so a genuine miss, by far the most common way
through this branch, never logs; only a value that was actually stored does.
Reaching wc_get_logger() through the legacy proxy requires it to be non-null, so
type the property as such: the DI container always calls init(), and can_use()
already dereferences it unguarded. That also clears the baselined PHPStan error
for the existing call, so drop the entry rather than let it grow.
Refs WOO6-44
* fix: report the real outcome when logging an invalid cache entry
The warning emitted when a non-string cache entry is discarded ended with a
fixed "the version will be regenerated" clause, but the call sits above the
$generate branch and so fires for both outcomes. With $generate set to false
nothing is regenerated: the entry is deleted and null is returned, leaving the
log to describe something that never happened.
Pass $generate down to the logger and pick the closing clause from it. Both
clauses stay in the future tense because the log is written before either the
regeneration or the delete is attempted, so neither has happened yet at the
point the message is composed.
The existing assertion only matched up to the value type, so it could not have
caught this; it now pins the regeneration wording, and a new test pins the
deletion wording.
Refs WOO6-44
diff --git a/plugins/woocommerce/changelog/woo6-44-codex-review-cache-miss-returning-null-can-crash b/plugins/woocommerce/changelog/woo6-44-codex-review-cache-miss-returning-null-can-crash
new file mode 100644
index 00000000000..cfb98aecda8
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-44-codex-review-cache-miss-returning-null-can-crash
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent invalid object cache values from crashing the version string generator.
diff --git a/plugins/woocommerce/changelog/woo6-44-version-cache-cleanup-hardening b/plugins/woocommerce/changelog/woo6-44-version-cache-cleanup-hardening
new file mode 100644
index 00000000000..7bc53de5c00
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-44-version-cache-cleanup-hardening
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Log and stop redundantly deleting invalid version string cache entries, and correctly detect misses from object cache drop-ins that report them as null.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 8ade683547a..df45d7db27f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -60891,12 +60891,6 @@ parameters:
count: 4
path: src/Internal/CLI/Migrator/Runner.php
- -
- message: '#^Cannot call method call_function\(\) on Automattic\\WooCommerce\\Proxies\\LegacyProxy\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/Caches/VersionStringGenerator.php
-
-
message: '#^Method Automattic\\WooCommerce\\Internal\\Caches\\VersionStringGenerator\:\:init\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Caches/VersionStringGenerator.php b/plugins/woocommerce/src/Internal/Caches/VersionStringGenerator.php
index 8aff3f03609..4b577249145 100644
--- a/plugins/woocommerce/src/Internal/Caches/VersionStringGenerator.php
+++ b/plugins/woocommerce/src/Internal/Caches/VersionStringGenerator.php
@@ -32,9 +32,9 @@ class VersionStringGenerator {
/**
* Legacy proxy instance.
*
- * @var LegacyProxy|null
+ * @var LegacyProxy
*/
- private ?LegacyProxy $legacy_proxy = null;
+ private LegacyProxy $legacy_proxy;
/**
* Initialize the class dependencies.
@@ -68,8 +68,13 @@ class VersionStringGenerator {
/**
* Get the current version string for an ID.
*
- * If no version exists and $generate is true, a new version will be created.
- * If no version exists and $generate is false, null will be returned.
+ * If no valid version exists and $generate is true, a new version will be created.
+ * If no valid version exists and $generate is false, null will be returned.
+ *
+ * Cached values that aren't non-empty strings are treated as invalid and are never
+ * returned. When $generate is true they are overwritten by the newly generated
+ * version; when $generate is false they are deleted, which means a call with
+ * $generate set to false can write to the cache.
*
* @param string $id The ID to get the version string for.
* @param bool $generate Whether to generate a new version if one doesn't exist. Default true.
@@ -82,17 +87,31 @@ class VersionStringGenerator {
$this->validate_input( $id );
$cache_key = $this->get_cache_key( $id );
- $version = wp_cache_get( $cache_key, self::CACHE_GROUP );
+ $found = false;
+ $version = wp_cache_get( $cache_key, self::CACHE_GROUP, false, $found );
+
+ if ( ! is_string( $version ) || '' === $version ) {
+ $entry_exists = $this->cache_entry_exists( $version, $found );
+
+ if ( $entry_exists ) {
+ $this->log_invalid_cached_value( $id, $version, $generate );
+ }
+
+ if ( $generate ) {
+ // The new version is written to the same cache key, replacing the invalid
+ // value, so deleting it first would only add a redundant round-trip.
+ return $this->generate_version( $id );
+ }
- if ( false === $version ) {
- if ( ! $generate ) {
- return null;
+ if ( $entry_exists ) {
+ wp_cache_delete( $cache_key, self::CACHE_GROUP );
}
- $version = $this->generate_version( $id );
- } else {
- // Refresh the cache lifetime.
- $this->store_version( $id, $version );
+
+ return null;
}
+
+ // Refresh the cache lifetime.
+ $this->store_version( $id, $version );
return $version;
}
@@ -143,18 +162,60 @@ class VersionStringGenerator {
// Some object cache implementations may return non-boolean values.
// Verify the store by reading the value back.
- $stored_value = wp_cache_get( $cache_key, self::CACHE_GROUP );
+ $found = false;
+ $stored_value = wp_cache_get( $cache_key, self::CACHE_GROUP, false, $found );
if ( $stored_value === $version ) {
return true;
}
// The stored value doesn't match; clean up and report failure.
- if ( false !== $stored_value ) {
+ if ( $this->cache_entry_exists( $stored_value, $found ) ) {
wp_cache_delete( $cache_key, self::CACHE_GROUP );
}
return false;
}
+ /**
+ * Tell whether a value read from the cache is evidence that an entry is stored.
+ *
+ * Object cache drop-ins replace wp_cache_get() wholesale, so neither the returned
+ * value nor the found flag is trustworthy on its own: some drop-ins never populate
+ * $found, and some signal a miss with null rather than false. The value is therefore
+ * the primary evidence, with $found as a tie-breaker that tells a stored false apart
+ * from a genuine miss. $found is checked loosely because a drop-in may report it as a
+ * truthy non-boolean.
+ *
+ * @param mixed $value The value returned by wp_cache_get().
+ * @param mixed $found The found flag as populated by wp_cache_get(), if it populates it at all.
+ * @return bool True if an entry appears to be stored, false if this looks like a miss.
+ */
+ private function cache_entry_exists( $value, $found ): bool {
+ return (bool) $found || ( false !== $value && null !== $value );
+ }
+
+ /**
+ * Log a value found in the version string cache that isn't a usable version string.
+ *
+ * This should never happen with a well-behaved object cache, so surface it for
+ * diagnosis rather than silently self-healing.
+ *
+ * @param string $id The ID the invalid value was cached for.
+ * @param mixed $value The invalid cached value.
+ * @param bool $regenerating Whether a replacement version is being generated.
+ * @return void
+ */
+ private function log_invalid_cached_value( string $id, $value, bool $regenerating ): void {
+ $this->legacy_proxy->call_function( 'wc_get_logger' )->warning(
+ sprintf(
+ 'Discarded an invalid version string cache entry for ID "%1$s" (got %2$s); %3$s.',
+ $id,
+ gettype( $value ),
+ $regenerating ? 'the version will be regenerated' : 'the entry will be deleted'
+ ),
+ array( 'source' => 'version-string-generator' )
+ );
+ }
+
/**
* Delete the version string for an ID by deleting its cached entry.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/VersionStringGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/VersionStringGeneratorTest.php
index e81adab07da..572664a168a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Caches/VersionStringGeneratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/VersionStringGeneratorTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Internal\Caches;
use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use WC_Unit_Test_Case;
/**
@@ -12,6 +13,8 @@ use WC_Unit_Test_Case;
*/
class VersionStringGeneratorTest extends WC_Unit_Test_Case {
+ use LoggerSpyTrait;
+
/**
* The System Under Test.
*
@@ -19,6 +22,13 @@ class VersionStringGeneratorTest extends WC_Unit_Test_Case {
*/
private $sut;
+ /**
+ * The real object cache, saved while a mock is installed in its place.
+ *
+ * @var \WP_Object_Cache|null
+ */
+ private $original_object_cache = null;
+
/**
* Runs before each test.
*/
@@ -34,6 +44,7 @@ class VersionStringGeneratorTest extends WC_Unit_Test_Case {
* Runs after each test.
*/
public function tearDown(): void {
+ $this->restore_object_cache();
remove_all_filters( 'woocommerce_version_string_generator_ttl' );
$this->sut = null;
parent::tearDown();
@@ -50,6 +61,50 @@ class VersionStringGeneratorTest extends WC_Unit_Test_Case {
return $constant;
}
+ /**
+ * Get the cache key the SUT uses for an ID.
+ *
+ * @param string $id The ID to get the cache key for.
+ * @return string
+ */
+ private function get_version_cache_key( string $id ): string {
+ return 'wc_version_string_' . md5( $id );
+ }
+
+ /**
+ * Install a mock object cache in place of the real one.
+ *
+ * The real cache is restored in tearDown, so tests don't need to unwind it themselves.
+ *
+ * @return \WP_Object_Cache|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private function install_mock_object_cache() {
+ global $wp_object_cache;
+
+ $mock = $this->createMock( \WP_Object_Cache::class );
+
+ $this->original_object_cache = $wp_object_cache;
+ $wp_object_cache = $mock; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ return $mock;
+ }
+
+ /**
+ * Restore the real object cache, if a mock was installed in its place.
+ *
+ * @return void
+ */
+ private function restore_object_cache(): void {
+ global $wp_object_cache;
+
+ if ( null === $this->original_object_cache ) {
+ return;
+ }
+
+ $wp_object_cache = $this->original_object_cache; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $this->original_object_cache = null;
+ }
+
/**
* @testdox can_use caches the result and returns the same value on subsequent calls.
*/
@@ -118,6 +173,177 @@ class VersionStringGeneratorTest extends WC_Unit_Test_Case {
$this->assertFalse( $cached_value, 'No cache entry should be created when generate is false' );
}
+ /**
+ * @testdox get_version does not delete on a genuine cache miss.
+ */
+ public function test_get_version_does_not_delete_on_genuine_cache_miss(): void {
+ $mock_cache = $this->install_mock_object_cache();
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'get' )
+ ->willReturnCallback(
+ static function ( $key, $group, $force, &$found ) {
+ $found = false;
+ return false;
+ }
+ );
+ $mock_cache->expects( $this->never() )->method( 'delete' );
+
+ $version = $this->sut->get_version( 'genuine-cache-miss', false );
+
+ $this->assertNull( $version, 'A genuine cache miss should return null when generation is disabled' );
+ }
+
+ /**
+ * @testdox get_version does not delete when the cache signals a miss with null instead of false.
+ */
+ public function test_get_version_does_not_delete_on_null_cache_miss(): void {
+ $mock_cache = $this->install_mock_object_cache();
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'get' )
+ ->willReturn( null );
+ $mock_cache->expects( $this->never() )->method( 'delete' );
+
+ $version = $this->sut->get_version( 'null-cache-miss', false );
+
+ $this->assertNull( $version, 'A null miss should be treated as a miss, not as a stored invalid value' );
+ }
+
+ /**
+ * @testdox get_version deletes a stored false when the cache reports found as a truthy non-boolean.
+ */
+ public function test_get_version_deletes_stored_false_when_found_flag_is_truthy(): void {
+ $mock_cache = $this->install_mock_object_cache();
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'get' )
+ ->willReturnCallback(
+ static function ( $key, $group, $force, &$found ) {
+ $found = 1;
+ return false;
+ }
+ );
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( $this->get_version_cache_key( 'stored-false-truthy-found' ), $this->get_cache_group() )
+ ->willReturn( true );
+
+ $version = $this->sut->get_version( 'stored-false-truthy-found', false );
+
+ $this->assertNull( $version, 'A stored false should be treated as an invalid cached value' );
+ }
+
+ /**
+ * @testdox get_version accepts a cached string when the cache does not set the found flag.
+ */
+ public function test_get_version_accepts_cached_string_without_found_flag(): void {
+ $cached_version = 'cached-version';
+
+ $mock_cache = $this->install_mock_object_cache();
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'get' )
+ ->willReturn( $cached_version );
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'set' )
+ ->with( $this->get_version_cache_key( 'cached-string-without-found' ), $cached_version, $this->get_cache_group(), DAY_IN_SECONDS )
+ ->willReturn( true );
+ $mock_cache->expects( $this->never() )->method( 'delete' );
+
+ $version = $this->sut->get_version( 'cached-string-without-found' );
+
+ $this->assertSame( $cached_version, $version, 'A valid cached string should not depend on the found flag' );
+ }
+
+ /**
+ * @testdox get_version deletes an invalid non-false value when the cache does not set the found flag.
+ */
+ public function test_get_version_deletes_invalid_non_false_value_without_found_flag(): void {
+ $mock_cache = $this->install_mock_object_cache();
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'get' )
+ ->willReturn( true );
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( $this->get_version_cache_key( 'invalid-value-without-found' ), $this->get_cache_group() )
+ ->willReturn( true );
+
+ $version = $this->sut->get_version( 'invalid-value-without-found', false );
+
+ $this->assertNull( $version, 'An invalid cached value should be treated as a miss' );
+ }
+
+ /**
+ * @testdox get_version replaces an invalid cached value without a separate delete when generating.
+ */
+ public function test_get_version_does_not_delete_when_generating_a_replacement(): void {
+ $mock_cache = $this->install_mock_object_cache();
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'get' )
+ ->willReturn( array( 'unexpected' ) );
+ $mock_cache
+ ->expects( $this->once() )
+ ->method( 'set' )
+ ->with( $this->get_version_cache_key( 'invalid-value-regenerated' ), $this->anything(), $this->get_cache_group(), DAY_IN_SECONDS )
+ ->willReturn( true );
+ $mock_cache->expects( $this->never() )->method( 'delete' );
+
+ $version = $this->sut->get_version( 'invalid-value-regenerated' );
+
+ $this->assertTrue( wp_is_uuid( (string) $version, 4 ), 'A new version should be generated over the invalid value' );
+ }
+
+ /**
+ * @testdox get_version logs a warning when it discards an invalid cached value.
+ */
+ public function test_get_version_logs_when_discarding_invalid_cached_value(): void {
+ $cache_key = $this->get_version_cache_key( 'logged-invalid-value' );
+ wp_cache_set( $cache_key, 42, $this->get_cache_group() );
+
+ $this->sut->get_version( 'logged-invalid-value' );
+
+ $this->assertLogged(
+ 'warning',
+ 'Discarded an invalid version string cache entry for ID "logged-invalid-value" (got integer); the version will be regenerated.',
+ array( 'source' => 'version-string-generator' )
+ );
+ }
+
+ /**
+ * @testdox get_version logs the deletion outcome when generation is disabled.
+ */
+ public function test_get_version_logs_deletion_outcome_when_generation_disabled(): void {
+ $cache_key = $this->get_version_cache_key( 'logged-invalid-value-no-generate' );
+ wp_cache_set( $cache_key, 42, $this->get_cache_group() );
+
+ $this->sut->get_version( 'logged-invalid-value-no-generate', false );
+
+ // Nothing is regenerated on this path, so the message must not claim it is.
+ $this->assertLogged(
+ 'warning',
+ 'Discarded an invalid version string cache entry for ID "logged-invalid-value-no-generate" (got integer); the entry will be deleted.',
+ array( 'source' => 'version-string-generator' )
+ );
+ }
+
+ /**
+ * @testdox get_version does not log on a genuine cache miss.
+ */
+ public function test_get_version_does_not_log_on_genuine_cache_miss(): void {
+ $this->sut->get_version( 'unlogged-cache-miss' );
+
+ $this->assertEmpty(
+ $this->captured_logs,
+ 'A genuine cache miss is the common path and should never be logged'
+ );
+ }
+
/**
* @testdox get_version generates version by default when it doesn't exist.
*/
@@ -133,6 +359,61 @@ class VersionStringGeneratorTest extends WC_Unit_Test_Case {
$this->assertEquals( $version, $cached_value );
}
+ /**
+ * Invalid values that can be returned by an object cache.
+ *
+ * @return array<string, array{mixed}>
+ */
+ public static function invalid_cached_version_values(): array {
+ return array(
+ 'boolean false' => array( false ),
+ 'empty string' => array( '' ),
+ 'null' => array( null ),
+ 'array' => array( array( 'unexpected' ) ),
+ 'integer' => array( 42 ),
+ );
+ }
+
+ /**
+ * @testdox get_version replaces invalid cached values when generation is enabled.
+ * @dataProvider invalid_cached_version_values
+ *
+ * @param mixed $invalid_value Invalid cached value.
+ */
+ public function test_get_version_replaces_invalid_cached_value_when_generation_enabled( $invalid_value ): void {
+ $cache_key = 'wc_version_string_' . md5( 'invalid-cached-version' );
+ wp_cache_set( $cache_key, $invalid_value, $this->get_cache_group() );
+
+ $version = $this->sut->get_version( 'invalid-cached-version' );
+
+ $this->assertIsString( $version, 'A new version string should be generated' );
+ $this->assertTrue( wp_is_uuid( (string) $version, 4 ), 'The generated version should be a UUID' );
+ $this->assertSame(
+ $version,
+ wp_cache_get( $cache_key, $this->get_cache_group() ),
+ 'The invalid cached value should be replaced'
+ );
+ }
+
+ /**
+ * @testdox get_version deletes invalid cached values when generation is disabled.
+ * @dataProvider invalid_cached_version_values
+ *
+ * @param mixed $invalid_value Invalid cached value.
+ */
+ public function test_get_version_deletes_invalid_cached_value_when_generation_disabled( $invalid_value ): void {
+ $cache_key = 'wc_version_string_' . md5( 'invalid-cached-version' );
+ wp_cache_set( $cache_key, $invalid_value, $this->get_cache_group() );
+
+ $version = $this->sut->get_version( 'invalid-cached-version', false );
+
+ $this->assertNull( $version, 'Generation-disabled cache misses should return null' );
+
+ $found = null;
+ wp_cache_get( $cache_key, $this->get_cache_group(), false, $found );
+ $this->assertFalse( $found, 'The invalid cached value should be deleted' );
+ }
+
/**
* @testdox generate_version sets a new version for a not yet versioned ID.
*/