In Apache 2.4, when mod_deflate compresses a response body, it will also modify the ETag header (if present) and append -gzip to it. This is to comply with HTTP specs and make sure that different representations of the resource also have different ETags.
Here's the relevant parts of results for two curl requests to a simple controller that just sets ETag: some-etag on the response. One is without compression, the other one enables it.
#> curl -I -X GET 'http://my.app/test'
HTTP/1.1 200 OK
Date: Sat, 16 Nov 2019 16:54:59 GMT
Server: Apache
ETag: "some-etag" <--- HERE
Last-Modified: Fri, 15 Nov 2019 23:00:00 GMT
Vary: Accept-Encoding
Transfer-Encoding: chunked
Content-Type: text/html; charset=UTF-8
versus
#> curl -I -X GET 'http://my.app/test' --compressed
HTTP/1.1 200 OK
Date: Sat, 16 Nov 2019 16:56:25 GMT
Server: Apache
ETag: "some-etag-gzip" <--- HERE
Last-Modified: Fri, 15 Nov 2019 23:00:00 GMT
Vary: Accept-Encoding
Content-Encoding: gzip
Content-Length: 10208
Content-Type: text/html; charset=UTF-8
From the client's perspective, "some-etag-gzip" is the opaque ETag value which will be included in revalidation requests.
Since Apache/mod_deflate fails to revert the change upon such requests (and probably cannot reasonably do so, since it cannot make assumptions about the meaning of the ETag), the modified value will be the one reaching PHP and Symfony. This effectively breaks ETag-based validation as outlined in the documentation. The controller code won't be short-cut and a 200 response will be generated instead of 304.
This behavior has been reported as a bug in Apache back in 2008. From that report, it seems mod_brotli faces the same challenge.
A configuration option DeflateAlterETag has been added in Apache 2.5 to either turn off ETag modification (possibly causing other issues?) or to remove ETags altogether from compressed responses. For mod_brotli, a BrotliAlterETag switch is available in Apache 2.4 already.
A possible workaround suggested is to add the following to the web server configuration:
RequestHeader edit "If-None-Match" '^"((.*)-(gzip|br))"$' '"$1", "$2"'
This will turn request headers like If-None-Match: "some-etag-gzip" into If-None-Match: "some-etag-gzip", "some-etag". I can confirm that this passes Symfony's \Symfony\Component\HttpFoundation\Response::isNotModified() as one would expect.
My suggestion is to add a warning/heads-up notice at https://symfony.com/doc/current/http_cache/validation.html#validation-with-the-etag-header. This could include the workaround configuration (if we want to endorse it) and/or point to this issue.
@mpdude I'm speechless about your issue report ... because it couldn't be better 馃槏 . It's just perfect 馃憦 ... so we took inspiration from it to fix this problem in #12796. Thanks a lot for this amazing contribution!
Thank you @javiereguiluz and the others involved, you did the actual work
Most helpful comment
@mpdude I'm speechless about your issue report ... because it couldn't be better 馃槏 . It's just perfect 馃憦 ... so we took inspiration from it to fix this problem in #12796. Thanks a lot for this amazing contribution!