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 changelog.d/362.security.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ plain `Cache-Control`: `@cache(ttl=0, must_revalidate=True)` sent `max-age=0,
must-revalidate`, which RFC 9111 §3.5 lets a shared cache such as a CDN store
and reuse for other users after revalidation. Such a response now gets `private`
like on every other route (`private, max-age=0, must-revalidate`), unless the
route is `public=True` or `cache_authorized=True`. No bypass warning is logged
route is `public=True`. No bypass warning is logged
for these routes, since they never read the backend.
12 changes: 12 additions & 0 deletions changelog.d/372.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
**`cache_authorized=True` routes now send `private` to requests with
credentials, and session dependencies add `Vary`.** The option keys the backend
entry on the caller, but a shared cache in front of the app keys on the URL
alone, and the response kept the decorator's header: `max-age=60,
must-revalidate` let a CDN serve one user's answer to the next under RFC 9111
§3.5, and a session token in `X-Session-Token` or a cookie needs no directive
at all. Such responses (200, cache hit and 304) now carry `private` in place of
`public`, and the backend is still used. `get_session`, `get_optional_session`
and the dependencies built on them now make `FastAPICacheXSessionMiddleware`
(and the deprecated `SessionMiddleware`) add `Vary` for the token sources, as
reading `request.session` already did. Routes that serve the same answer to
every user should use `public=True`.
19 changes: 13 additions & 6 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ private, no positive ttl, or Authorization/session without public/cache_authoriz
│ builder does not run; for Authorization or a session,
│ Cache-Control says private instead of public)
↓ no
(cache_authorized with Authorization or a session: the backend is used below,
but every answer still says private instead of public)
↓
Build the cache key: key_builder (default method|||host|||path|||query_params),
plus one name=value component per vary header
↓
Expand Down Expand Up @@ -128,7 +131,7 @@ The decorator arguments control both the server-side behaviour and the
@cache(ttl=3600) # Cache for 1 hour (also used as the max-age value)
@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/session requests use the backend
@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # Authorization/session requests use the backend, answered private
@cache(immutable=True) # Content never changes

# Header-only directives (they do not change server-side behaviour)
Expand Down Expand Up @@ -174,7 +177,9 @@ 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
> or a session bypasses the backend (see below).
> or a session bypasses the backend (see below). With it, the answer is
> still sent with `private`, since a shared cache downstream cannot see the
> identity in the key.
>
> 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 @@ -218,8 +223,9 @@ if no_store:

bypass = private or not ttl
# Authorization header, a session the middleware loaded, or non-empty request.session
credential = None if private or public or cache_authorized else request_credential(request)
if bypass or credential:
credential = None if private or public else request_credential(request)
header = private_header if credential else decorator_header # for every answer below
if bypass or (credential and not cache_authorized):
response, etag = await render() # backend neither read nor written
return not_modified(...) if etag_matches(client_etag, etag) else response

Expand Down Expand Up @@ -280,7 +286,8 @@ return response
> `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` or session request, are sent (200 or 304) with `private`
> to any `Authorization` or session request (bypassed or, with
> `cache_authorized`, served from the backend), 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`
Expand Down Expand Up @@ -461,7 +468,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` 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) |
| 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); with `cache_authorized=True` the backend is used but `Cache-Control` still has `private` |
| 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
33 changes: 20 additions & 13 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,12 @@ A response that belongs to one caller is never stored either (#296):
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)).
`public=True` routes are exempt. Routes with `cache_authorized=True`, the
opt-in for a key builder that includes the caller's identity (see
[Authenticated endpoints](#authenticated-endpoints)), read and write the
backend for such requests but still answer them with `private`: the entry is
per caller only in the backend, while a CDN keys on the URL alone (before
0.3.9 they sent the decorator's header unchanged, #372).
`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 route without a positive `ttl` skips the
Expand Down Expand Up @@ -189,7 +192,8 @@ matches what the handler returns:
`cache_authorized=True` together with a `key_builder` that puts the verified
caller's identity into the key, so each user gets their own entry (see
[Authenticated endpoints](#authenticated-endpoints)). Without the identity in
the key, one user's response is served to the next. If you do not need a
the key, one user's response is served to the next. The response still goes
out with `private`, so downstream only the user's browser keeps a copy. If you do not need a
server-side cache for it, leave both options unset (or use `private=True`)
and let only the browser cache it.

Expand Down Expand Up @@ -532,7 +536,7 @@ request it is given selects.
> caller's response to everyone, so use one of the two options above.

```python
from fastapi import Request, Response
from fastapi import Request

from fastapi_cachex import build_cache_key
from fastapi_cachex import cache
Expand All @@ -557,17 +561,20 @@ def per_user_key(request: Request) -> str:

@app.get("/me/dashboard")
@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
# carries the identity so such a cache keeps one copy per user.
response.headers["Vary"] = "Authorization"
async def my_dashboard(user: CurrentUser):
# Sent as `Cache-Control: private, max-age=60` to a request with
# `Authorization` or a session: only this backend and the user's browser
# keep a copy.
return build_dashboard(user)
```

A per-user entry is only safe from shared caches in front of your app if they
honour `Vary` for that header. When they don't, or when identity comes from
something a shared cache cannot see, use option 1 instead.
The per-user entry lives only in your backend. A shared cache in front of the
app (CDN, reverse proxy) sees only the URL, so every response to a request with
`Authorization` or a session carries `private`, even with `cache_authorized`.
When identity comes from a cookie of your own instead, nothing marks the
request as credentialed and the decorator's header goes out unchanged: set
`private=True` (option 1), or send `Vary: Cookie` yourself if a shared cache
may store it.

> [!CAUTION]
> The key builder decides who sees whose data, so the identity it reads must
Expand Down
6 changes: 4 additions & 2 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ async def me(session=Depends(require_user_session)):
one would be logged in along with the victim. Log in with `await login(request, user)`, which
gives the session a new ID and attaches the user (see
[Regenerate the Session ID After Login](#5-regenerate-the-session-id-after-login)).
- Any access to `request.session` adds `Vary` for every request header read to find the token:
- Any access to `request.session`, or a read through the session dependencies (`get_session`,
`get_optional_session` and those built on them, such as `AuthenticatedSession`), adds `Vary`
for every request header read to find the token:
the headers checked in `token_source_priority` order (`header_name`, and `Authorization` when
bearer tokens are enabled) up to the one that carried the token. `Cookie` is added only when
no header carried a token, because only then is the cookie read.
Expand All @@ -209,7 +211,7 @@ async def me(session=Depends(require_user_session)):
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
the session's user caches per user, still answered with `private`. See
[Authenticated endpoints](HTTP_CACHING.md#authenticated-endpoints).

The cookie is always `HttpOnly`; `Secure`, `SameSite`, `Domain`, `Path` and `Max-Age` follow the
Expand Down
52 changes: 29 additions & 23 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,9 @@ def cache(
``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``.
With this option the response to such a request still carries
``private``: its entry is per caller only in this backend, while
a shared cache downstream keys on the URL alone.
A token that does not resolve to a session does not count.
A route without a positive ``ttl`` skips the backend anyway, but
its response to such a request is still sent with ``private``;
Expand Down Expand Up @@ -1227,13 +1230,15 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
# carried its token (#319). Routes without a positive ttl skip the
# backend anyway, but their response still needs `private` for a
# downstream cache (#362); only `private=True` already sends it.
credential = (
None
if private or public or cache_authorized
else _request_credential(req)
# `cache_authorized` lifts the bypass but not `private`: its entries
# are per caller only in this backend, while a shared cache
# downstream keys on the URL alone (#372).
credential = None if private or public else _request_credential(req)
authorized_bypass = credential is not None and not cache_authorized
response_cache_control = (
cache_control if credential is None else private_cache_control
)
authorized_bypass = credential is not None
if credential is not None:
if credential is not None and not cache_authorized:
logger.debug(
"%s present; bypassing the backend for path=%s",
credential,
Expand All @@ -1251,35 +1256,32 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
# (see `bypass_backend`). ETag revalidation still works: it
# compares the client's validator against freshly rendered content.
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`, 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
)
# `response_cache_control` has `private` for a request with
# credentials: RFC 9111 §3.5 would still let a downstream shared
# cache reuse the answer to an `Authorization` request under
# `must-revalidate`, and nothing at all stops one reusing the
# answer to a cookie.
response, _, etag = await _render(func, req, *args, **kwargs)
if not _is_cacheable_status(response.status_code):
return response
if etag is None:
# StreamingResponse/FileResponse — cannot compute ETag
return _with_cache_control(
response, bypass_cache_control, private_cache_control
response, response_cache_control, private_cache_control
)
if _etag_matches(client_etag, etag):
logger.debug("304 Not Modified (uncached); path=%s", req.url.path)
return _not_modified(
etag,
_cache_control_for(
response, bypass_cache_control, private_cache_control
response, response_cache_control, private_cache_control
),
response.headers,
)
response.headers["ETag"] = etag
logger.debug("Bypassed the backend; path=%s", req.url.path)
return _with_cache_control(
response, bypass_cache_control, private_cache_control
response, response_cache_control, private_cache_control
)

# Built only here: the branches above never touch the backend, so a
Expand Down Expand Up @@ -1318,7 +1320,9 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
if current_etag is None:
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
return _with_cache_control(
current_response, cache_control, private_cache_control
current_response,
response_cache_control,
private_cache_control,
)

if _etag_matches(client_etag, current_etag):
Expand All @@ -1327,7 +1331,9 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
return _not_modified(
current_etag,
_cache_control_for(
current_response, cache_control, private_cache_control
current_response,
response_cache_control,
private_cache_control,
),
current_response.headers,
)
Expand All @@ -1344,7 +1350,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
# takes the new Age with it (RFC 9111 §4.3.4).
return _not_modified(
cached_data.fingerprint,
cache_control,
response_cache_control,
cached_data.headers,
_age_headers(cached_data, ttl),
)
Expand All @@ -1360,7 +1366,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
headers={
**(cached_data.headers or {}),
"ETag": cached_data.fingerprint,
"Cache-Control": cache_control,
"Cache-Control": response_cache_control,
**_age_headers(cached_data, ttl),
},
)
Expand All @@ -1383,7 +1389,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
if current_etag is None:
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
return _with_cache_control(
current_response, cache_control, private_cache_control
current_response, response_cache_control, private_cache_control
)
logger.debug("Cache MISS; computed fresh ETag for key=%s", cache_key)

Expand Down Expand Up @@ -1424,7 +1430,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)

return _with_cache_control(
current_response, cache_control, private_cache_control
current_response, response_cache_control, private_cache_control
)

wrapper: AsyncResponseCallable = serve
Expand Down
14 changes: 13 additions & 1 deletion fastapi_cachex/session/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from fastapi_cachex.exceptions import ProxyNotSetError

from .middleware import _SESSION_READ_KEY
from .middleware import _log_in
from .middleware import _RequestSession
from .middleware import get_client_ip
Expand Down Expand Up @@ -53,6 +54,17 @@ def get_optional_session(
Returns:
Session object or None if not authenticated
"""
return _read_session(request)


def _read_session(request: Request) -> Session | None:
"""The session the middleware loaded for ``request``, or None.

Records the read, so the session middleware adds ``Vary`` for the headers
the token may come from: the response depends on them even when the
handler never touches ``request.session`` (#372).
"""
setattr(request.state, _SESSION_READ_KEY, True)
return getattr(request.state, "__fastapi_cachex_session", None)


Expand All @@ -77,7 +89,7 @@ def get_session(
Raises:
HTTPException: 401 if session not found
"""
session: Session | None = getattr(request.state, "__fastapi_cachex_session", None)
session = _read_session(request)
if session is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down
Loading
Loading