From dac8f70bc8a2d3ff0f8c48598362bac7745705be Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Oct 2026 17:36:05 +0100 Subject: [PATCH] keep mismatched Accept headers out of the LiteSpeed cache --- .github/changelog/fix-litespeed-vary | 4 +++ integration/class-litespeed-cache.php | 29 ++++++++++++++- .../class-test-litespeed-cache.php | 36 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .github/changelog/fix-litespeed-vary diff --git a/.github/changelog/fix-litespeed-vary b/.github/changelog/fix-litespeed-vary new file mode 100644 index 0000000000..d850c324e0 --- /dev/null +++ b/.github/changelog/fix-litespeed-vary @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fixed LiteSpeed Cache possibly showing visitors the technical ActivityPub version of a page instead of the normal page. diff --git a/integration/class-litespeed-cache.php b/integration/class-litespeed-cache.php index f88f40d5f8..1afa71e978 100644 --- a/integration/class-litespeed-cache.php +++ b/integration/class-litespeed-cache.php @@ -7,6 +7,7 @@ namespace Activitypub\Integration; +use function Activitypub\accept_prefers_activitypub; use function Activitypub\is_plugin_active; /** @@ -16,6 +17,13 @@ */ class Litespeed_Cache { + /** + * The `Accept` pattern that puts a request into the JSON cache bucket. + * + * @var string + */ + const ACCEPT_PATTERN = '^[\s,]*(application/activity\+json|application/ld\+json[^,]*activitystreams)'; + /** * The rules to add to the htaccess file. * @@ -23,7 +31,7 @@ class Litespeed_Cache { */ public static $rules = ' RewriteEngine On -RewriteCond %{HTTP:Accept} ^[\s,]*(application/activity\+json|application/ld\+json[^,]*activitystreams) [NC] +RewriteCond %{HTTP:Accept} ' . self::ACCEPT_PATTERN . ' [NC] RewriteRule ^ - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+isjson] '; @@ -63,6 +71,7 @@ public static function init() { } \add_filter( 'site_status_tests', array( self::class, 'add_site_health_test' ) ); + \add_action( 'template_redirect', array( self::class, 'maybe_disable_cache' ) ); // Remove rules if LiteSpeed Cache is not active but rules were previously set. } elseif ( \get_option( self::$option_name ) ) { @@ -73,6 +82,24 @@ public static function init() { \add_action( 'deleted_plugin', array( self::class, 'on_plugin_deleted' ) ); } + /** + * Keep a response out of the cache when its cache bucket and its representation can disagree. + * + * The rewrite rule picks the bucket by whether an ActivityPub type comes first in `Accept`, while + * the plugin picks the representation by quality. When the two disagree, the response could be + * stored in the other representation's bucket, so it is not cached at all. + * + * @return void + */ + public static function maybe_disable_cache() { + $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $bucket = (bool) \preg_match( \sprintf( '#%s#i', self::ACCEPT_PATTERN ), $accept ); + + if ( accept_prefers_activitypub( $accept ) !== $bucket ) { + \do_action( 'litespeed_control_set_nocache', 'ActivityPub: Accept order and quality disagree' ); + } + } + /** * Clean up htaccess rules when LiteSpeed Cache plugin is deleted. * diff --git a/tests/phpunit/tests/integration/class-test-litespeed-cache.php b/tests/phpunit/tests/integration/class-test-litespeed-cache.php index 50a9ac768f..45e87c176f 100644 --- a/tests/phpunit/tests/integration/class-test-litespeed-cache.php +++ b/tests/phpunit/tests/integration/class-test-litespeed-cache.php @@ -160,6 +160,42 @@ public function test_rewrite_condition_matches_accept_prefers_activitypub() { } } + /** + * A response is kept out of the cache when the rewrite rule and the plugin disagree on the Accept header. + * + * @covers ::maybe_disable_cache + * @dataProvider accept_cache_provider + * + * @param string $accept The Accept header. + * @param bool $no_cache Whether the response must not be cached. + */ + public function test_maybe_disable_cache( $accept, $no_cache ) { + $_SERVER['HTTP_ACCEPT'] = $accept; + $before = \did_action( 'litespeed_control_set_nocache' ); + + Litespeed_Cache::maybe_disable_cache(); + + unset( $_SERVER['HTTP_ACCEPT'] ); + + $this->assertSame( $no_cache, \did_action( 'litespeed_control_set_nocache' ) > $before ); + } + + /** + * Accept headers for test_maybe_disable_cache. + * + * @return array[] + */ + public function accept_cache_provider() { + return array( + 'activitypub' => array( 'application/activity+json', false ), + 'mastodon' => array( 'application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, text/html;q=0.1', false ), + 'browser' => array( 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', false ), + 'empty' => array( '', false ), + 'activitypub wins, listed second' => array( 'text/html;q=0.1, application/activity+json', true ), + 'activitypub listed first, loses' => array( 'application/activity+json;q=0.1, text/html', true ), + ); + } + /** * Test that the option is deleted when rules are removed. *