From 5e633f615975c502a05c79c5b3ae1d426745a996 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Thu, 20 Aug 2026 14:48:43 +0200 Subject: [PATCH 1/2] Add ETag value normalization to HttpCacheMiddleware Introduce ETagValueNormalizerInterface to normalize raw ETag values from the If-None-Match request header before comparison. This fixes cache validation when a web server compression module (e.g. Apache mod_deflate or mod_brotli) appends a suffix such as -gzip or -br to the ETag value. Includes NullETagValueNormalizer (default, no changes) and SuffixETagValueNormalizer (removes the first matching suffix). Fixes #11 --- CHANGELOG.md | 3 +- docs/guide/en/http-cache-middleware.md | 26 ++++++++++ .../ETagValueNormalizerInterface.php | 23 +++++++++ .../NullETagValueNormalizer.php | 16 +++++++ .../SuffixETagValueNormalizer.php | 48 +++++++++++++++++++ src/HttpCache/HttpCacheMiddleware.php | 10 +++- .../NullETagValueNormalizerTest.php | 22 +++++++++ .../SuffixETagValueNormalizerTest.php | 47 ++++++++++++++++++ tests/HttpCache/HttpCacheMiddlewareTest.php | 30 ++++++++++++ 9 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php create mode 100644 src/HttpCache/ETagValueNormalizer/NullETagValueNormalizer.php create mode 100644 src/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizer.php create mode 100644 tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php create mode 100644 tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 547555e..30675fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 1.2.2 under development -- no changes in this release. +- New #11: Add ETag value normalization in `HttpCacheMiddleware` via `ETagValueNormalizerInterface` with + `NullETagValueNormalizer` and `SuffixETagValueNormalizer` implementations (@KalimeroMK) ## 1.2.1 August 10, 2026 diff --git a/docs/guide/en/http-cache-middleware.md b/docs/guide/en/http-cache-middleware.md index f2befe8..524aeb4 100644 --- a/docs/guide/en/http-cache-middleware.md +++ b/docs/guide/en/http-cache-middleware.md @@ -90,6 +90,32 @@ Default: `new DefaultETagGenerator()` An instance of `ETagGeneratorInterface` that generates a string `ETag` value based on the provided seed. +### `$eTagValueNormalizer` + +Type: `Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface` + +Default: `new NullETagValueNormalizer()` + +An instance of `ETagValueNormalizerInterface` that normalizes raw ETag values obtained from the `If-None-Match` +request header before comparing them with the application generated ETag value. + +Normalization is needed when an intermediary, such as a web server compression module, modifies the ETag header value. +For example, Apache `mod_deflate` and `mod_brotli` append `-gzip` and `-br` suffixes to the ETag value +(see [mod_deflate documentation](https://httpd.apache.org/docs/2.4/mod/mod_deflate.html#deflatealteretag)). + +Implementations out of the box: + +- `NullETagValueNormalizer` — returns ETag values unmodified. +- `SuffixETagValueNormalizer` — removes the first matching suffix from a given list of suffixes. + +Example usage for a server that appends compression suffixes: + +```php +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\SuffixETagValueNormalizer; + +$eTagValueNormalizer = new SuffixETagValueNormalizer(['-gzip', '-br']); +``` + ## `Cache-Control` header value providers A provider should implement the `CacheControlProviderInterface` interface to supply the value of the `Cache-Control` diff --git a/src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php b/src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php new file mode 100644 index 0000000..280369f --- /dev/null +++ b/src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php @@ -0,0 +1,23 @@ + $suffix + */ + public function __construct(string|array $suffix) + { + $this->suffixes = is_string($suffix) ? [$suffix] : $suffix; + } + + public function normalize(string $value): string + { + foreach ($this->suffixes as $suffix) { + if ($suffix !== '' && str_ends_with($value, $suffix)) { + return substr($value, 0, -strlen($suffix)); + } + } + return $value; + } +} diff --git a/src/HttpCache/HttpCacheMiddleware.php b/src/HttpCache/HttpCacheMiddleware.php index bf3e0d4..f2763ed 100644 --- a/src/HttpCache/HttpCacheMiddleware.php +++ b/src/HttpCache/HttpCacheMiddleware.php @@ -16,6 +16,8 @@ use Yiisoft\HttpMiddleware\HttpCache\ETagGenerator\ETagGeneratorInterface; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\ETagProviderInterface; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\NullETagProvider; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\NullETagValueNormalizer; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\LastModifiedProviderInterface; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\NullLastModifiedProvider; @@ -33,6 +35,8 @@ final class HttpCacheMiddleware implements MiddlewareInterface * @param LastModifiedProviderInterface $lastModifiedProvider The last modified dates provider. * @param ETagProviderInterface $eTagProvider The provider for {@see ETag}. * @param ETagGeneratorInterface $eTagGenerator The {@see ETag} string values generator. + * @param ETagValueNormalizerInterface $eTagValueNormalizer The normalizer for raw ETag values obtained from + * the `If-None-Match` request header. */ public function __construct( private readonly ResponseFactoryInterface $responseFactory, @@ -40,6 +44,7 @@ public function __construct( private readonly LastModifiedProviderInterface $lastModifiedProvider = new NullLastModifiedProvider(), private readonly ETagProviderInterface $eTagProvider = new NullETagProvider(), private readonly ETagGeneratorInterface $eTagGenerator = new DefaultETagGenerator(), + private readonly ETagValueNormalizerInterface $eTagValueNormalizer = new NullETagValueNormalizer(), ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -145,11 +150,12 @@ private function extractRawETagValues(ServerRequestInterface $request): array } return array_map( - static function (string $value): string { + function (string $value): string { /** * @var string We use a correct pattern, so `preg_replace` always returns a string. */ - return preg_replace('~^\s*(?:W/)?"([^"]+)"\s*$~', '$1', $value); + $rawETag = preg_replace('~^\s*(?:W/)?"([^"]+)"\s*$~', '$1', $value); + return $this->eTagValueNormalizer->normalize($rawETag); }, explode(',', $rawValue), ); diff --git a/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php b/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php new file mode 100644 index 0000000..c5a9632 --- /dev/null +++ b/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php @@ -0,0 +1,22 @@ +normalize('tag1')); + assertSame('tag1-gzip', $normalizer->normalize('tag1-gzip')); + assertSame('', $normalizer->normalize('')); + } +} diff --git a/tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php b/tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php new file mode 100644 index 0000000..3768284 --- /dev/null +++ b/tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php @@ -0,0 +1,47 @@ +normalize($value)); + } + + public function testStringSuffix(): void + { + $normalizer = new SuffixETagValueNormalizer('-gzip'); + + assertSame('tag1', $normalizer->normalize('tag1-gzip')); + assertSame('tag1-br', $normalizer->normalize('tag1-br')); + } + + public function testOnlyFirstMatchingSuffixIsRemoved(): void + { + $normalizer = new SuffixETagValueNormalizer(['-gzip', '-br']); + + assertSame('content-gzip', $normalizer->normalize('content-gzip-br')); + } + + public function testEmptySuffixIsIgnored(): void + { + $normalizer = new SuffixETagValueNormalizer(''); + + assertSame('tag1', $normalizer->normalize('tag1')); + } +} diff --git a/tests/HttpCache/HttpCacheMiddlewareTest.php b/tests/HttpCache/HttpCacheMiddlewareTest.php index c0b29af..57c6682 100644 --- a/tests/HttpCache/HttpCacheMiddlewareTest.php +++ b/tests/HttpCache/HttpCacheMiddlewareTest.php @@ -16,6 +16,7 @@ use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\NullETagProvider; use Yiisoft\HttpMiddleware\HttpCache\HttpCacheMiddleware; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\PredefinedETagProvider; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\SuffixETagValueNormalizer; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\NullLastModifiedProvider; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\PredefinedLastModifiedProvider; use Yiisoft\HttpMiddleware\Tests\Support\FakeRequestHandler; @@ -173,6 +174,35 @@ public function testIfNoneMatchEquals(array $ifNoneMatchValues): void ); } + #[TestWith([['"tag1-gzip"']])] + #[TestWith([['W/"tag1-gzip"']])] + public function testIfNoneMatchEqualsWithSuffixNormalizer(array $ifNoneMatchValues): void + { + $request = new ServerRequest( + headers: [ + 'If-None-Match' => $ifNoneMatchValues, + ], + ); + $middleware = new HttpCacheMiddleware( + new ResponseFactory(), + eTagProvider: new PredefinedETagProvider([new ETag('tag1')]), + eTagGenerator: new CallableETagGenerator( + static fn(string $seed) => $seed, + ), + eTagValueNormalizer: new SuffixETagValueNormalizer(['-gzip', '-br']), + ); + + $response = $middleware->process($request, new FakeRequestHandler()); + + assertSame(304, $response->getStatusCode()); + assertSame( + [ + 'ETag' => ['"tag1"'], + ], + $response->getHeaders(), + ); + } + public function testIfNoneMatchWithoutEtag(): void { $request = new ServerRequest( From ab620f877a9fd15cbcdcb91f038e6bb2fe660125 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Thu, 20 Aug 2026 15:08:48 +0200 Subject: [PATCH 2/2] Kill escaped mutants in HttpCacheMiddleware - Remove redundant early return for an empty If-None-Match header value: in_array() with an empty list already returns false (equivalent mutant reported by Infection). - Add a test asserting the ETag value normalizer is not called for an empty If-None-Match header value. MSI for the file is now 100%. --- src/HttpCache/HttpCacheMiddleware.php | 3 -- tests/HttpCache/HttpCacheMiddlewareTest.php | 33 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/HttpCache/HttpCacheMiddleware.php b/src/HttpCache/HttpCacheMiddleware.php index f2763ed..5b360f9 100644 --- a/src/HttpCache/HttpCacheMiddleware.php +++ b/src/HttpCache/HttpCacheMiddleware.php @@ -87,9 +87,6 @@ private function validateCache( } $headerETags = $this->extractRawETagValues($request); - if ($headerETags === []) { - return false; - } return in_array($eTagHeader->rawValue(), $headerETags, true); } diff --git a/tests/HttpCache/HttpCacheMiddlewareTest.php b/tests/HttpCache/HttpCacheMiddlewareTest.php index 57c6682..b996ead 100644 --- a/tests/HttpCache/HttpCacheMiddlewareTest.php +++ b/tests/HttpCache/HttpCacheMiddlewareTest.php @@ -16,11 +16,13 @@ use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\NullETagProvider; use Yiisoft\HttpMiddleware\HttpCache\HttpCacheMiddleware; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\PredefinedETagProvider; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface; use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\SuffixETagValueNormalizer; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\NullLastModifiedProvider; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\PredefinedLastModifiedProvider; use Yiisoft\HttpMiddleware\Tests\Support\FakeRequestHandler; +use function PHPUnit\Framework\assertFalse; use function PHPUnit\Framework\assertSame; final class HttpCacheMiddlewareTest extends TestCase @@ -247,6 +249,37 @@ public function testEmptyIfNoneMatch(): void ); } + public function testEmptyIfNoneMatchDoesNotCallETagValueNormalizer(): void + { + $request = new ServerRequest( + headers: [ + 'If-None-Match' => [''], + ], + ); + $normalizer = new class implements ETagValueNormalizerInterface { + public bool $called = false; + + public function normalize(string $value): string + { + $this->called = true; + return $value; + } + }; + $middleware = new HttpCacheMiddleware( + new ResponseFactory(), + eTagProvider: new PredefinedETagProvider([new ETag('test')]), + eTagGenerator: new CallableETagGenerator( + static fn(string $seed) => $seed, + ), + eTagValueNormalizer: $normalizer, + ); + + $response = $middleware->process($request, new FakeRequestHandler()); + + assertSame(200, $response->getStatusCode()); + assertFalse($normalizer->called); + } + public function testIfModifiedSinceTrue(): void { $request = new ServerRequest(