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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ 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 plus `cache_authorized=True`
> (requests with `Authorization` otherwise bypass the backend) — see
> (requests with `Authorization` or a session otherwise bypass the backend) — see
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).

## Documentation
Expand Down
10 changes: 10 additions & 0 deletions changelog.d/319.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**`@cache` no longer shares a response to a request that arrived with a
session.** Only `Authorization` bypassed the backend, so a plain `@cache` on a
route that read the session served the first visitor's response to everyone:
the session token in `X-Session-Token` or the session cookie, and anonymous
sessions (a cart in `request.session`), were not recognised. A request now
bypasses the backend and is answered with `private`, as for `Authorization`,
when the session middleware loaded a session for it (from any token transport,
with or without a user) or `request.session` is non-empty under any session
middleware. A token that resolves to no session does not count. `public=True`
and `cache_authorized=True` lift the bypass as before.
21 changes: 12 additions & 9 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ 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, or Authorization without public/cache_authorized?
private, or Authorization/session 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)
│ Authorization or a session, Cache-Control says private
│ instead of public)
↓ no
Read the backend entry
↓
Expand Down Expand Up @@ -120,9 +121,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, also for Authorization requests
@cache(public=True) # Allow shared caches, also for Authorization/session 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(ttl=60, key_builder=per_user_key, cache_authorized=True) # Authorization/session requests use the backend
@cache(immutable=True) # Content never changes

# Header-only directives (they do not change server-side behaviour)
Expand Down Expand Up @@ -165,7 +166,7 @@ The header value is built once per decorated route:
> 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).
> or a session 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 @@ -248,21 +249,23 @@ return 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
> no write), and so does one with a session (loaded by the session middleware
> from any token transport, or a non-empty `request.session`), 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`
> to a bypassed `Authorization` or session 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).
> responses were stored and replayed to every caller (#296); session
> requests were not bypassed until 0.3.9 either (#319).

### 4. ETag generation and validation

Expand Down Expand Up @@ -429,7 +432,7 @@ 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) |
| Request with `Authorization` or a session | 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 |
Expand Down
24 changes: 18 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: | Requests with `Authorization` still use the backend (otherwise they bypass it). |
| `public` | `public=True` | :white_check_mark: | Requests with `Authorization` or a session 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 @@ -93,7 +93,7 @@ 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
- **The request carries `Authorization` or a session.** 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
Expand All @@ -105,6 +105,14 @@ A response that belongs to one caller is never stored either (#296):
`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.
A request has a session when `FastAPICacheXSessionMiddleware` (or the
deprecated `SessionMiddleware`) loaded one for it, from the token header, a
bearer token or the session cookie, with or without a user, or when
`request.session` is non-empty under any session middleware, Starlette's
included. A token that resolves to no session (forged, expired) does not
count, so it cannot be used to skip the cache. Before 0.3.9 only
`Authorization` did, and a plain `@cache` on a route that read the session
served one visitor's response to the next (#319).
- **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.
Expand Down Expand Up @@ -292,7 +300,7 @@ stays readable; if yours carries a secret, key on it through a `key_builder`

`vary=["Authorization"]` does not lift the rule for authorized requests (see
[Authenticated endpoints](#authenticated-endpoints)): a request with an
`Authorization` header still bypasses the backend unless the route is
`Authorization` header (or a session) still bypasses the backend unless the route is
`public=True` or passes `cache_authorized=True`. Without either, only the
anonymous `authorization=` entry is ever stored.

Expand All @@ -310,7 +318,7 @@ these is what you want instead:
- `private=True`, which leaves per-visitor responses to the browser cache.

The per-caller rules still apply: a request carrying a cookie is cached (only
`Authorization` triggers the bypass), but a response that sets a cookie is
`Authorization` or a session triggers the bypass), but a response that sets a cookie is
never stored and is sent with `private`, so a route that refreshes a session
cookie on every request stores nothing. If you do want `vary=["Cookie"]`,
silence the warning with the standard filter, before the module defining the
Expand Down Expand Up @@ -377,7 +385,11 @@ request it is given selects.
> do want a server-side cache per user. Leave `private` unset: `private=True`
> 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.
> header or a session: without it such requests bypass the backend too.
>
> If callers authenticate with a cookie of your own rather than the library's
> session, nothing triggers the bypass: a plain `@cache` serves the first
> caller's response to everyone, so use one of the two options above.

```python
from fastapi import Request, Response
Expand Down Expand Up @@ -433,7 +445,7 @@ something a shared cache cannot see, use option 1 instead.

The key builder runs only when `@cache` reads or writes the backend, so it is not
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
with `Authorization` or a session 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
6 changes: 6 additions & 0 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,12 @@ async def me(session=Depends(get_session)):
`request.session`. Otherwise a CDN or reverse proxy could store the token and hand it to the
next visitor. Responses without a token keep their headers. The deprecated `SessionMiddleware`
does the same when it sends a token in its response header.
- `@cache` does not read or write its backend for a request that arrived with a session (one
the middleware loaded, from any transport, with or without a user, or a non-empty
`request.session`), and answers it with `private`, as for `Authorization`. `public=True`
shares the route across sessions; `cache_authorized=True` with a `key_builder` that includes
the session's user caches per user. See
[Authenticated endpoints](HTTP_CACHING.md#authenticated-endpoints).

The cookie is always `HttpOnly`; `Secure`, `SameSite`, `Domain`, `Path` and `Max-Age` follow the
`cookie_*` settings.
Expand Down
60 changes: 46 additions & 14 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,30 @@ def _vary_components(request: Request, names: Sequence[str]) -> list[str]:
return components


# Where `FastAPICacheXSessionMiddleware` (and the deprecated
# `SessionMiddleware`) put the session they loaded; `get_session` reads it.
_SESSION_STATE_KEY = "__fastapi_cachex_session"


def _request_credential(request: Request) -> str | None:
"""What identifies the caller of ``request``, or ``None`` if nothing does.

``Authorization``, a session the session middleware loaded (from the
token header, a bearer token or the session cookie, anonymous or not), or
a non-empty ``request.session`` from any session middleware (Starlette's
cookie sessions included). Only a token that resolved to a session
counts, so an invalid or expired one does not keep a request away from
the cache.
"""
if "authorization" in request.headers:
return "Authorization header"
if getattr(request.state, _SESSION_STATE_KEY, None) is not None:
return "Session"
if request.scope.get("session"):
return "Session data"
return None


_COOKIE_VARY_WARNING = (
"cache vary on Cookie: @cache(vary=[...]) lists Cookie, so every distinct "
"Cookie header gets its own entry and the number of entries grows with "
Expand Down Expand Up @@ -773,9 +797,13 @@ def cache(
leaves the response unstored. ``False`` lets the error propagate,
so the request fails.
cache_authorized: Read and write the backend for requests that carry
an ``Authorization`` header. By default such a request bypasses
the backend as ``private=True`` does (RFC 9111 §3.5), unless
an ``Authorization`` header or arrive with a session: one the
session middleware loaded (from its token header, a bearer token
or the session cookie, with or without a user) or a non-empty
``request.session``. By default such a request bypasses the
backend as ``private=True`` does (RFC 9111 §3.5), unless
``public`` is set, and its response is sent with ``private``.
A token that does not resolve to a session does not count.
RFC 9111 would also allow reuse under ``must-revalidate``, but
``must_revalidate=True`` does not lift the bypass: only this
explicit opt-in or ``public`` does. Set this only when
Expand All @@ -797,8 +825,8 @@ def cache(
appears in the key; missing or empty, they stay ``name=``.
Listing ``Cookie`` emits a ``UserWarning`` when the decorator is
applied, since every visitor then gets their own entry.
A request with ``Authorization`` still bypasses the backend
unless ``public`` or ``cache_authorized`` is set, and a response
A request with ``Authorization`` or a session still bypasses the
backend unless ``public`` or ``cache_authorized`` is set, and a response
that sets a cookie is still not stored.

Returns:
Expand Down Expand Up @@ -892,7 +920,7 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable:
)
# Sent instead for a response that must not be stored downstream
# either: one that sets a cookie, or one answering an `Authorization`
# request the backend was bypassed for. `public` becomes `private`;
# or session request the backend was bypassed for. `public` becomes `private`;
# `no_cache` leaves the scope out of `cache_control`, so it is added.
private_cache_control = (
f"{DirectiveType.PRIVATE.value}, {cache_control}"
Expand Down Expand Up @@ -954,16 +982,19 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
# RFC 9111 §3.5: a shared cache must not reuse a response to a
# request with `Authorization` unless the response allows it. The
# default key carries no identity, so treat such requests as
# private unless the route is `public` or opted in.
authorized_bypass = (
not bypass_backend
and not public
and not cache_authorized
and "authorization" in req.headers
# private unless the route is `public` or opted in. A request that
# arrived with a session is the same case, whichever transport
# carried its token (#319).
credential = (
None
if bypass_backend or public or cache_authorized
else _request_credential(req)
)
authorized_bypass = credential is not None
if authorized_bypass:
logger.debug(
"Authorization header present; bypassing the backend for path=%s",
"%s present; bypassing the backend for path=%s",
credential,
req.url.path,
)

Expand All @@ -976,8 +1007,9 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
if bypass_backend or authorized_bypass:
# Without `public`/`cache_authorized`, RFC 9111 §3.5 would still
# let a downstream shared cache reuse the answer to an
# `Authorization` request under `must-revalidate`; `private`
# rules that out.
# `Authorization` request under `must-revalidate`, and nothing
# at all stops one reusing the answer to a cookie; `private`
# rules both out.
bypass_cache_control = (
private_cache_control if authorized_bypass else cache_control
)
Expand Down
Loading
Loading