Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ async def report(cache: AppCache):

> [!WARNING]
> The default cache key carries no user identity. Cache authenticated endpoints
> with `private=True` or a per-user key builder — see
> with `private=True` or a per-user key builder plus `cache_authorized=True`
> (requests with `Authorization` otherwise bypass the backend) — see
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).

## Documentation
Expand Down
14 changes: 14 additions & 0 deletions changelog.d/296.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
**`@cache` no longer stores responses that belong to one caller.** A request
with an `Authorization` header now bypasses the backend like `private=True`
(RFC 9111 §3.5), unless the route is `public=True` or opts in with the new
`cache_authorized=True`, meant for a `key_builder` that includes the verified
caller's identity. A response whose own `Cache-Control` contains `private` or
`no-store`, or that sets a cookie, is served but not stored, and the handler's
`private`/`no-store` header is no longer replaced by the decorator's. A cookie
response and a bypassed `Authorization` response are sent with `private` in
place of `public` (keeping the other directives; `private, no-cache` on
`no_cache` routes), so a CDN or proxy does not store them either;
`must_revalidate=True` does not lift the bypass. Previously all three were
stored and replayed to every caller, so one user's response could reach
another. The per-user example in HTTP_CACHING.md ("Authenticated endpoints")
now passes `cache_authorized=True`.
49 changes: 42 additions & 7 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ Build the cache key: method|||host|||path|||query_params
no-store? ── yes → run the handler, neither read nor write the cache,
│ respond with Cache-Control: no-store
↓ no
private? ── yes → run the handler; compare If-None-Match to decide 304 or 200
│ (the shared backend is neither read nor written)
private, or Authorization without public/cache_authorized?
── yes → run the handler; compare If-None-Match to decide 304 or 200
│ (the shared backend is neither read nor written; for
│ Authorization, Cache-Control says private instead of public)
↓ no
Read the backend entry
↓
Expand All @@ -35,10 +37,15 @@ Cached entry exists, ttl is set, and no-cache is off?
├─ non-2xx (or 206) → return as-is and **do not write**
│ (an existing good entry is not overwritten)
├─ streaming/file response → no ETag can be computed; return as-is, do not write
├─ handler sent Cache-Control private/no-store, or Set-Cookie
│ → set the ETag, return it, **do not write** (an existing
│ entry is left alone; a private/no-store header is kept)
└─ regular response → set the ETag; write to the backend only if it
differs from the existing entry's ETag
↓
Attach Cache-Control to the response (non-2xx responses are returned without it)
Attach Cache-Control to the response (non-2xx responses are returned without it,
a handler's own private/no-store Cache-Control is never replaced, and a
Set-Cookie response gets private instead of public)
```

## Detailed steps
Expand Down Expand Up @@ -102,8 +109,9 @@ The decorator arguments control both the server-side behaviour and the

# Normal caching behaviour
@cache(ttl=3600) # Cache for 1 hour (also used as the max-age value)
@cache(public=True) # Allow shared caches
@cache(public=True) # Allow shared caches, also for Authorization requests
@cache(private=True) # Private only; never touches the shared backend
@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # Authorization requests use the backend
@cache(immutable=True) # Content never changes

# Header-only directives (they do not change server-side behaviour)
Expand Down Expand Up @@ -143,8 +151,10 @@ The header value is built once per decorated route:
> sends `Cache-Control: private` so the user's own browser can cache the
> response, and `If-None-Match` is still compared against freshly rendered
> content.
> 2. A custom `key_builder` that includes the identity — when you really do want
> a per-user server-side cache.
> 2. A custom `key_builder` that includes the identity, together with
> `cache_authorized=True` — when you really do want a per-user server-side
> cache. Without `cache_authorized`, a request with an `Authorization` header
> bypasses the backend (see below).
>
> Take the identity from a trusted source (a verified token claim, a
> dependency-injected user object); do not trust unchecked client headers.
Expand Down Expand Up @@ -182,7 +192,8 @@ if request.method != "GET":
if no_store:
return await render() # no read, no write

if private or not ttl:
authorized = "authorization" in request.headers and not (public or cache_authorized)
if private or not ttl or authorized:
response, etag = await render() # backend neither read nor written
return not_modified(...) if etag_matches(client_etag, etag) else response

Expand All @@ -208,6 +219,8 @@ if not is_cacheable_status(response.status_code):
return response # non-2xx: returned as-is, not written
if etag is None:
return response # streaming/file: no ETag, not written
if marked_private_or_no_store(response) or "set-cookie" in response.headers:
return response # one caller's response: not written
if not entry or entry.fingerprint != etag:
await backend.set(cache_key, CacheEntry(...), ttl=ttl)
return response
Expand All @@ -221,6 +234,25 @@ return response
> `304` and are returned without the decorator's `Cache-Control` header (only
> `no_store=True` adds `no-store` to every response).

> [!NOTE]
> **Responses that belong to one caller are never stored.** Following RFC 9111
> §3.5, a request with an `Authorization` header bypasses the backend (no read,
> no write) unless the route is `public=True` or opts in with
> `cache_authorized=True` (for a `key_builder` that includes the verified
> identity). On a render, a response whose own `Cache-Control` contains
> `private` or `no-store` (whole directive, any case), or that sets a cookie,
> is served but not written. A `private`/`no-store` header from the handler is
> sent unchanged instead of the decorator's. A cookie response, and the answer
> to a bypassed `Authorization` request, are sent (200 or 304) with `private`
> in place of `public` and the decorator's other directives kept (`private,
> no-cache` on a `no_cache` route), so a downstream shared cache does not
> store them either. `must_revalidate=True` does not lift the `Authorization`
> bypass, although RFC 9111 would allow reuse under `must-revalidate`: the
> library requires the explicit opt-in. An entry already stored under the
> key is left alone, and a request that hits it before the handler runs is
> served from it as usual. Each skip is logged at `DEBUG`. Before 0.3.9 such
> responses were stored and replayed to every caller (#296).

### 4. ETag generation and validation

The ETag is computed from the response body and used to detect whether the
Expand Down Expand Up @@ -386,6 +418,9 @@ lookup. Which backend to pick is covered in [Backends](BACKENDS.md#choosing-a-ba
| `no_store=True` | The cache is neither read nor written; the endpoint runs every time |
| `no_cache=True` | The endpoint runs every time to recompute the ETag; a match with the client's `If-None-Match` still returns 304, and the cache is updated when the ETag changes |
| `private=True` | The **shared backend** is neither read nor written; `Cache-Control: private` is still sent and the ETag is compared against fresh content |
| Request with `Authorization` | The backend is neither read nor written, as with `private=True`, and `Cache-Control` has `private` instead of `public`, unless the route has `public=True` or `cache_authorized=True` (`must_revalidate=True` is not enough) |
| Handler sends `Cache-Control: private`/`no-store` | Returned with the handler's header intact, not written, and any existing entry is left untouched |
| Response sets a cookie | Returned with `private` instead of `public` in `Cache-Control`, not written, and any existing entry is left untouched |
| No `ttl` (or `ttl=0`) | The backend is neither read nor written, as with `private=True`; the endpoint runs every time and the ETag is compared against fresh content |
| Cache expired (TTL elapsed) | The endpoint runs again; `MemoryBackend` deletes the expired entry in place when it reads it |
| Non-2xx or 206 response | Returned as-is, not written, and any existing entry is left untouched |
Expand Down
41 changes: 35 additions & 6 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ header, and the server-side cache behaves the same with or without them.
| `no-cache` | `no_cache=True` | :white_check_mark: | The handler runs on every request; the response is still stored, and a matching `If-None-Match` gets a 304. |
| `no-store` | `no_store=True` | :white_check_mark: | Nothing is read or stored, and no ETag is set. |
| `private` | `private=True` | :white_check_mark: | The backend is bypassed; the handler runs on every request, and ETag revalidation still works. |
| `public` | `public=True` | :white_check_mark: | None (header only). |
| `public` | `public=True` | :white_check_mark: | Requests with `Authorization` still use the backend (otherwise they bypass it). |
| `immutable` | `immutable=True` | :white_check_mark: | None (header only). |
| `must-revalidate` | `must_revalidate=True` | :white_check_mark: | None (header only). |
| `stale-while-revalidate` | `stale="revalidate", stale_ttl=N` | :white_check_mark: | None (header only): the server-side cache never serves stale content. |
Expand Down Expand Up @@ -89,8 +89,34 @@ Only successful responses are stored. A response the handler *returns* with a
non-2xx status (for example `Response(..., status_code=404)`) is passed straight
through and never cached, so a transient error cannot replace or poison the last
good entry. `206 Partial Content` is excluded as well, since its body is only
meaningful for the `Range` request that produced it. `Set-Cookie` is never
stored or replayed.
meaningful for the `Range` request that produced it.

A response that belongs to one caller is never stored either (#296):

- **The request carries `Authorization`.** As RFC 9111 §3.5 requires of a
shared cache, the backend is bypassed, as with `private=True`: nothing is
read or written, the handler runs, and `If-None-Match` is compared against
the fresh render. The response (and a 304) is sent with `private` in place
of `public`, keeping the decorator's other directives (`private, no-cache`
on a `no_cache` route), so a CDN or proxy does not store it either.
`public=True` routes are exempt, and so are routes with
`cache_authorized=True`, the opt-in for a key builder that includes the
caller's identity (see [Authenticated endpoints](#authenticated-endpoints)).
`must_revalidate=True` does not lift the bypass: RFC 9111 would let a shared
cache reuse such a response under `must-revalidate`, but the library
requires an explicit opt-in.
- **The handler's own `Cache-Control` contains `private` or `no-store`**
(as whole directives, in any case). The response is served but not stored,
and the handler's header is sent unchanged instead of the decorator's.
- **The response sets a cookie.** It is served, `Set-Cookie` included, but not
stored, and it (and a 304) is sent with `private` in place of `public`,
keeping the other directives, so a shared cache downstream does not store it
either.

In the last two cases an entry already stored under the key is left alone, and
a request that finds a valid entry is still answered from it before the
handler runs. A handler's own `private`/`no-store` header always wins, and
`no_store=True` still sends only `no-store`. Each skip is logged at `DEBUG`.

A handler that returns plain data instead of a `Response` gets the same
treatment it would without `@cache`: the value is validated and filtered by the
Expand Down Expand Up @@ -184,7 +210,9 @@ HTTP requests.
> rendered content.
> 2. **A key builder that includes the caller's identity** — use this when you
> do want a server-side cache per user. Leave `private` unset: `private=True`
> bypasses the backend, so the key builder would never be used.
> bypasses the backend, so the key builder would never be used. Pass
> `cache_authorized=True` when callers authenticate with an `Authorization`
> header: without it such requests bypass the backend too.

```python
from fastapi import Request, Response
Expand Down Expand Up @@ -217,7 +245,7 @@ def per_user_key(request: Request) -> str:


@app.get("/me/dashboard")
@cache(ttl=60, key_builder=per_user_key)
@cache(ttl=60, key_builder=per_user_key, cache_authorized=True)
async def my_dashboard(user: CurrentUser, response: Response):
# Without `private`, the response goes out as `Cache-Control: max-age=60`,
# which a shared cache (CDN, reverse proxy) may store. Vary on whatever
Expand Down Expand Up @@ -245,7 +273,8 @@ something a shared cache cannot see, use option 1 instead.
> sending `X-User-Id: <someone-else>` returns that user's cached response.

The key builder runs only when `@cache` reads or writes the backend, so it is not
called for `no_store=True`, `private=True` or routes without a `ttl`. Before 0.3.8
called for `no_store=True`, `private=True`, routes without a `ttl`, or requests
with `Authorization` on a route without `public=True` or `cache_authorized=True`. Before 0.3.8
it was, only to feed a debug log. Keep it free of side effects.

## Clearing the cache
Expand Down
Loading
Loading