Skip to content

fix(cache): never store responses that belong to one caller - #305

Merged
allen0099 merged 3 commits into
masterfrom
fix/cache-unshareable-responses
Sep 27, 2026
Merged

allen0099 merged 3 commits into
masterfrom
fix/cache-unshareable-responses

Conversation

@allen0099

@allen0099 allen0099 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Problem

@cache(ttl=...) wrote a response to the shared backend even when it belonged to one caller, and then replaced the handler's Cache-Control with the decorator's (#296):

  • the handler's response had Cache-Control: private or no-store;
  • the response set a cookie (Set-Cookie was stripped from the entry, but the body it came with was stored and replayed to everyone);
  • the request carried Authorization (RFC 9111 §3.5 forbids a shared cache from reusing such a response unless it allows it).

Repro from the issue: a handler that sets private, no-store and returns the Authorization header served {"user": "Bearer alice"} to bob, with Cache-Control: max-age=60.

Fix

All in fastapi_cachex/cache.py:

  1. Authorization requests bypass the backend. No read and no write, through the existing private/no-ttl bypass path, so If-None-Match still gets a 304 when it matches the fresh render. Two exceptions: public=True (as RFC 9111 §3.5 allows), and the new keyword-only opt-in cache_authorized: bool = False. Use cache_authorized for routes whose key_builder includes the verified caller identity. must_revalidate=True does not lift the bypass: RFC 9111 would allow reuse under must-revalidate, but the library requires an explicit opt-in. The docstring and docs say so.
  2. The handler marks its response private or no-store. The check matches whole directive tokens, case-insensitively, across every Cache-Control field. The response is served but not written. The handler's header is sent unchanged: _with_cache_control no longer replaces it, and 304s on the bypass and no_cache paths repeat it too. no_store=True on the decorator still sends no-store unconditionally, because it is stricter than anything the handler can send.
  3. The response has Set-Cookie. It is served normally but not written.
  4. private for a downstream shared cache. A response that sets a cookie, and the answer to an Authorization request that bypassed the backend, are sent with private in place of public (200 and 304 alike). The decorator's other directives are kept (max-age, must-revalidate, stale-*, immutable). On a no_cache route the header becomes private, no-cache (plus must-revalidate when set). public is never sent on these responses, so a CDN or proxy does not store them either. Without this, must_revalidate=True alone would let a shared cache reuse an Authorization response. The private variant is built once at decoration time, like cache_control. A handler's own private/no-store header still wins, and no_store=True still sends only no-store.
  5. Each skip is logged at DEBUG.

In cases 2 and 3, an entry that is already stored under the key is left alone. This matches how a non-2xx render is handled: the stored entry came from a shareable response. A request that finds a valid entry is still answered from it before the handler runs. The handler only runs with a live entry present on no_cache routes, and those never serve the entry's body without revalidation. Routes that trigger none of these rules behave as before.

Docs: docs/HTTP_CACHING.md (directive table public row, the storage rules, "Authenticated endpoints": the per-user example now passes cache_authorized=True, and a note on when the key builder runs), docs/CACHE_FLOW.md (flow diagram, decision pseudo-code, a new note, the scenarios table), the README warning, and the zh-TW mirrors (i18n/zh-TW/docs/HTTP_CACHING.md, CACHE_FLOW.md, index.md). Nothing in examples/ caches a route that receives Authorization, so no example needed changes, and tests/test_examples.py passes.

Tests

New file tests/test_cache_unshareable.py (28 tests):

  • the issue repro (alice and bob with private, no-store);
  • private / no-store / mixed-case / multi-directive values are not stored, and the handler's header is kept;
  • guard: max-age, no-cache and x-private-hint, public are still stored (whole-token match only);
  • a Set-Cookie response is not stored and each request gets its own cookie;
  • an Authorization request neither reads an existing anonymous entry nor writes;
  • guard: an Authorization request still revalidates with a 304;
  • a 304 on the bypass path and on the no_cache path repeats the handler's private header;
  • public=True caches Authorization requests;
  • cache_authorized=True with a per-user key builder: an alice hit, and bob gets his own entry;
  • an unshareable render on a no_cache route leaves the existing entry alone;
  • no_store=True overrides the handler's header;
  • each of the three skips is logged at DEBUG;
  • private header: Set-Cookie on a public=True route with every directive gets private, max-age=60, must-revalidate, stale-while-revalidate=30, immutable and is not stored;
  • Set-Cookie 304 on a bypassed (ttl-less, public) route gets private;
  • Set-Cookie on a no_cache route gets private, no-cache, must-revalidate on the 200 and the 304;
  • Authorization with must_revalidate=True gets private, max-age=60, must-revalidate on the 200 and the 304 and is not stored;
  • Authorization on a no_cache route gets private, no-cache;
  • guards: opted-in Authorization routes keep the decorator's header (public, max-age=60 / max-age=60), and no_store=True still sends no-store over Set-Cookie.

The first commit's cookie and Authorization tests (setting_a_cookie_is_not_stored, authorization_request_bypasses_the_backend, authorization_request_still_revalidates) expected max-age=60. The second commit updates them to expect private, max-age=60.

Changed test: tests/test_cache_status_headers.py::test_set_cookie_is_never_replayed. It encoded the buggy behaviour: it expected the second request to be a cache hit of the cookie-setting response, without the cookie. It now asserts that the response is not stored at all: the handler runs twice, both responses carry the cookie and X-Safe, and the backend has no entry. The test's intent, that a cookie is never replayed, is kept and made stronger.

Mutation check. With cache.py reverted to master, 14 of the first commit's new or changed tests fail. The ones that pass on master are the guard tests (other directives stored, 304 for Authorization, public=True, no_store override), which pass by design. Each targeted mutation of the fix fails at least one test:

Mutation Failing tests
no Authorization bypass bypasses_the_backend, bypassed_304, log[Authorization]
drop the public exception public_route_caches_authorization_requests
drop the cache_authorized exception cache_authorized_caches_per_user_entries
no private/no-store skip marked_private_or_no_store ×4, leaves_an_existing_entry_alone, log[private]
no Set-Cookie skip set_cookie_is_never_replayed, setting_a_cookie_is_not_stored, log[cookie]
always overwrite the handler header issue_repro, marked_private_or_no_store ×4, leaves_an_existing_entry_alone
bypass-path 304 uses the decorator header bypassed_304_keeps_the_handler_cache_control
no_cache 304 uses the decorator header no_cache_304_keeps_the_handler_cache_control
no_store keeps the handler header no_store_decorator_overrides_the_handler_header
unshareable render evicts the entry leaves_an_existing_entry_alone
substring instead of token match other_directives_are_still_stored[x-private-hint, public]

For the second commit, with cache.py at the first commit, 8 of the 28 tests fail. These are the 5 new private-header tests plus the 3 updated ones. The 3 guards pass by design. The targeted mutations below were run on the full suite:

Mutation Failing tests
Authorization bypass sends the decorator header bypasses_the_backend, still_revalidates, must_revalidate_does_not_lift, authorization_on_a_no_cache_route
Set-Cookie keeps the decorator header setting_a_cookie_is_not_stored, set_cookie_on_a_public_route, set_cookie_304_on_a_bypassed_route, set_cookie_on_a_no_cache_route
no_cache variant without the private prefix set_cookie_on_a_no_cache_route, authorization_on_a_no_cache_route
private variant keeps public set_cookie_on_a_public_route, set_cookie_304_on_a_bypassed_route
private variant drops must-revalidate set_cookie_on_a_public_route, must_revalidate_does_not_lift
Authorization 304 sends the decorator header still_revalidates, must_revalidate_does_not_lift, authorization_on_a_no_cache_route
must_revalidate=True lifts the bypass must_revalidate_does_not_lift

Local results: uv run pytest: 885 passed, 191 skipped (live Redis/Memcached tests skipped), 93.70% coverage, cache.py at 100%. ruff check, ruff format --check, mypy --strict and pre-commit run --all-files are clean.

Compatibility

This changes behaviour under an unchanged API. A route that relied on sharing cached responses across Authorization requests now needs public=True or cache_authorized=True. Cookie-setting responses, and bypassed Authorization responses, now send private instead of public (or no scope).

CHANGELOG

The entry is in changelog.d/296.security.md and is merged into CHANGELOG.md at release time.

Closes #296

@cache wrote a response to the shared backend even when the handler
marked it private or no-store, when it set a cookie, or when the request
carried Authorization, and then replaced the handler's Cache-Control
with the decorator's. One user's response was replayed to everyone.

A request with Authorization now bypasses the backend like private=True
(RFC 9111 section 3.5), unless the route is public=True or opts in with
the new cache_authorized=True for identity-aware key builders. A
rendered response with private/no-store in its own Cache-Control, or
with Set-Cookie, is served but not written; an existing entry is left
alone, and a private/no-store header from the handler is kept. Each
skip is logged at DEBUG.

Closes #296
@allen0099 allen0099 added this to the 0.3.9 milestone Sep 27, 2026
@allen0099 allen0099 added bug Something isn't working http-cache The @cache decorator, cache keys and Cache-Control handling security Security vulnerability or hardening labels Sep 27, 2026
A response left unstored because it sets a cookie, or because it
answers an Authorization request the backend was bypassed for, still
went out with the decorator's Cache-Control, so a CDN or proxy could
store it (public explicitly allowed it, and must-revalidate alone lets
a shared cache reuse an Authorization response under RFC 9111 3.5).
Such responses, and their 304s, now carry private in place of public
with the other directives kept (private, no-cache on no_cache routes).
The variant is built once at decoration time. A handler's own
private/no-store header and no_store=True still win.

must_revalidate=True still does not lift the Authorization bypass; only
public=True or cache_authorized=True do. The docstring and docs say so.
@allen0099
allen0099 merged commit f34a842 into master Sep 27, 2026
12 checks passed
@allen0099
allen0099 deleted the fix/cache-unshareable-responses branch September 27, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working http-cache The @cache decorator, cache keys and Cache-Control handling security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@cache stores responses marked private/no-store, with Set-Cookie, or for Authorization requests

1 participant