diff --git a/changelog.d/362.security.md b/changelog.d/362.security.md index 7ace93a..b2360e5 100644 --- a/changelog.d/362.security.md +++ b/changelog.d/362.security.md @@ -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. diff --git a/changelog.d/372.security.md b/changelog.d/372.security.md new file mode 100644 index 0000000..cc3a97b --- /dev/null +++ b/changelog.d/372.security.md @@ -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`. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 518b0ae..abdae67 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -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 ↓ @@ -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) @@ -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. @@ -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 @@ -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` @@ -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 | diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index c52da3e..d6d2a05 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/docs/SESSION.md b/docs/SESSION.md index 3acf510..71c33ea 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -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. @@ -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 diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 7d0009e..6b3bcf1 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -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``; @@ -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, @@ -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 @@ -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): @@ -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, ) @@ -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), ) @@ -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), }, ) @@ -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) @@ -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 diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 1b18581..f424ee2 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -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 @@ -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) @@ -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, diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py index 5ba2de2..8431727 100644 --- a/fastapi_cachex/session/middleware.py +++ b/fastapi_cachex/session/middleware.py @@ -149,6 +149,16 @@ def _read_header_token( return None, consulted +# Set on the request state when a session dependency reads the loaded session, +# so the middleware knows the response depends on the token sources (#372). +_SESSION_READ_KEY = "__fastapi_cachex_session_read" + + +def _session_was_read(connection: HTTPConnection) -> bool: + """Whether a session dependency read the session for this request.""" + return bool(connection.scope.get("state", {}).get(_SESSION_READ_KEY)) + + def _forbid_storing(headers: MutableHeaders) -> None: """Keep a response that carries a session token out of every cache. @@ -316,9 +326,10 @@ async def dispatch( # Propagate renewed token to client so its JWT exp stays in sync response_token = renewed_token + if response_token is not None or _session_was_read(request): + add_vary(response.headers, _read_header_token(request, self.config)[1]) if response_token is not None: response.headers[self.config.header_name] = response_token - add_vary(response.headers, _read_header_token(request, self.config)[1]) _forbid_storing(response.headers) return response @@ -503,7 +514,7 @@ async def send_wrapper(message: Message) -> None: from_header=from_header, ) - if session.accessed or sent_token: + if session.accessed or sent_token or _session_was_read(connection): add_vary(headers, vary_on) if sent_token: _forbid_storing(headers) diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 95d7c3a..540ff23 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -18,6 +18,9 @@ private、沒有正數的 ttl,或帶有 Authorization/Session 且未設定 p │ (共用後端既不讀取也不寫入,key builder 也不會執行; │ Authorization 或 Session 的情況下 Cache-Control 以 private 取代 public) ↓ 否 +(設定 cache_authorized 且帶有 Authorization 或 Session:下方照常使用後端, +但每個回應仍以 private 取代 public) + ↓ 建立快取鍵:key_builder(預設為 method|||host|||path|||query_params), 再為每個 vary 標頭附加一個 name=value 段 ↓ @@ -102,7 +105,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25` @cache(ttl=3600) # 快取 1 小時(也作為 max-age 的值) @cache(public=True) # 允許共用快取,帶有 Authorization/Session 的請求也一樣 @cache(private=True) # 僅限私有;永遠不接觸共用後端 -@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # 帶有 Authorization/Session 的請求也使用後端 +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # 帶有 Authorization/Session 的請求也使用後端,以 private 回應 @cache(immutable=True) # 內容永不改變 # 只影響標頭的指令(不會改變伺服器端行為) @@ -130,7 +133,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25` > 對於回應內容取決於呼叫者的端點,請擇一處理: > > 1. `private=True`:永遠不讀取或寫入共用後端。它仍會送出 `Cache-Control: private`,讓使用者自己的瀏覽器可以快取回應,而且 `If-None-Match` 仍會與新產生的內容比對。 -> 2. 包含身分的自訂 `key_builder`,並搭配 `cache_authorized=True`:當你確實需要以使用者為單位的伺服器端快取時使用。未設定 `cache_authorized` 時,帶有 `Authorization` 標頭或 Session 的請求會繞過後端(見下方說明)。 +> 2. 包含身分的自訂 `key_builder`,並搭配 `cache_authorized=True`:當你確實需要以使用者為單位的伺服器端快取時使用。未設定 `cache_authorized` 時,帶有 `Authorization` 標頭或 Session 的請求會繞過後端(見下方說明)。設定之後,回應仍以 `private` 送出,因為下游的共用快取看不到鍵中的身分。 > > 身分請取自可信任的來源(已驗證的權杖 claim、透過依賴注入取得的使用者物件);不要信任未經檢查的用戶端標頭。 @@ -166,8 +169,9 @@ if no_store: bypass = private or not ttl # Authorization 標頭、中介軟體載入的 Session,或不是空的 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 # 用於下方每個回應 +if bypass or (credential and not cache_authorized): response, etag = await render() # 既不讀取也不寫入後端 return not_modified(...) if etag_matches(client_etag, etag) else response @@ -209,7 +213,7 @@ return response > 「非 2xx 不寫入」是刻意的設計:暫時性的錯誤不應抹除最後一次正常的快取回應,也不應在之後被當成 200 重播。`206 Partial Content` 同樣不會快取,因為它的內容只對產生它的那個 `Range` 請求有意義。非 2xx 回應也永遠不會以 `304` 回應,且回傳時不帶裝飾器的 `Cache-Control` 標頭(只有 `no_store=True` 會在每個回應加上 `no-store`)。 > [!NOTE] -> **屬於單一呼叫者的回應永遠不會被儲存。** 依照 RFC 9111 §3.5,帶有 `Authorization` 標頭的請求會繞過後端(不讀取也不寫入),帶有 Session 的請求(Session 中介軟體從任何權杖來源載入了 Session,或 `request.session` 不是空的)也一樣,除非路由設定了 `public=True`,或以 `cache_authorized=True` 明確選擇啟用(用於包含已驗證身分的 `key_builder`)。產生回應時,若回應自己的 `Cache-Control` 含有 `private` 或 `no-store`(完整指令,不分大小寫),或回應設定了 cookie,則照常回傳但不寫入。handler 送出的 `private`/`no-store` 標頭會原樣送出,不會被裝飾器的標頭取代。設定 cookie 的回應,以及繞過後端的 `Authorization` 或 Session 請求的回應(200 或 304),會以 `private` 取代 `public` 送出並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓下游的共用快取也不會儲存它們。`must_revalidate=True` 不會解除 `Authorization` 的繞過;雖然 RFC 9111 允許在 `must-revalidate` 下重複使用,本函式庫仍要求明確選擇啟用。該鍵下已儲存的項目保持不變,而在 handler 執行前就命中該項目的請求仍照常由它回應。每次略過都會以 `DEBUG` 等級記錄。0.3.9 以前這類回應會被儲存並重播給每位呼叫者(#296);帶有 Session 的請求在 0.3.9 以前也不會繞過(#319)。 +> **屬於單一呼叫者的回應永遠不會被儲存。** 依照 RFC 9111 §3.5,帶有 `Authorization` 標頭的請求會繞過後端(不讀取也不寫入),帶有 Session 的請求(Session 中介軟體從任何權杖來源載入了 Session,或 `request.session` 不是空的)也一樣,除非路由設定了 `public=True`,或以 `cache_authorized=True` 明確選擇啟用(用於包含已驗證身分的 `key_builder`)。產生回應時,若回應自己的 `Cache-Control` 含有 `private` 或 `no-store`(完整指令,不分大小寫),或回應設定了 cookie,則照常回傳但不寫入。handler 送出的 `private`/`no-store` 標頭會原樣送出,不會被裝飾器的標頭取代。設定 cookie 的回應,以及任何 `Authorization` 或 Session 請求的回應(無論繞過後端,或設定 `cache_authorized` 而由後端回應;200 或 304),會以 `private` 取代 `public` 送出並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓下游的共用快取也不會儲存它們。`must_revalidate=True` 不會解除 `Authorization` 的繞過;雖然 RFC 9111 允許在 `must-revalidate` 下重複使用,本函式庫仍要求明確選擇啟用。該鍵下已儲存的項目保持不變,而在 handler 執行前就命中該項目的請求仍照常由它回應。每次略過都會以 `DEBUG` 等級記錄。0.3.9 以前這類回應會被儲存並重播給每位呼叫者(#296);帶有 Session 的請求在 0.3.9 以前也不會繞過(#319)。 ### 4. ETag 產生與驗證 {#4-etag-generation-and-validation} @@ -363,7 +367,7 @@ async def cleanup_task(): | `no_store=True` | 既不讀取也不寫入快取;端點每次都會執行 | | `no_cache=True` | 端點每次都會執行以重新計算 ETag;與用戶端的 `If-None-Match` 相符時仍回傳 304,ETag 改變時會更新快取 | | `private=True` | **共用後端**既不讀取也不寫入;仍會送出 `Cache-Control: private`,並以新產生的內容比對 ETag | -| 帶有 `Authorization` 或 Session 的請求 | 與 `private=True` 一樣,既不讀取也不寫入後端,`Cache-Control` 以 `private` 取代 `public`,除非路由設定了 `public=True` 或 `cache_authorized=True`(`must_revalidate=True` 不算) | +| 帶有 `Authorization` 或 Session 的請求 | 與 `private=True` 一樣,既不讀取也不寫入後端,`Cache-Control` 以 `private` 取代 `public`,除非路由設定了 `public=True` 或 `cache_authorized=True`(`must_revalidate=True` 不算);設定 `cache_authorized=True` 時會使用後端,但 `Cache-Control` 仍帶有 `private` | | handler 送出 `Cache-Control: private`/`no-store` | 回傳時保留 handler 的標頭,不寫入,既有項目也保持不變 | | 回應設定了 cookie | 回傳時 `Cache-Control` 以 `private` 取代 `public`,不寫入,既有項目也保持不變 | | 沒有 `ttl`(或 `ttl=0`) | 與 `private=True` 一樣,既不讀取也不寫入後端;端點每次都會執行,並以新產生的內容比對 ETag | diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index feab9f6..b417fb2 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -87,7 +87,7 @@ GET /items → 200, Cache-Control: max-age=60, Age: 42(儲存後 42 秒送出 屬於單一呼叫者的回應同樣不會被儲存(#296): -- **請求帶有 `Authorization` 或 Session。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限,設定 `cache_authorized=True` 的路由也一樣;後者是給包含呼叫者身分的 key builder 使用的明確選項(見[需驗證身分的端點](#authenticated-endpoints))。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。沒有正數 `ttl` 的路由本來就不經過後端,但它對這類請求的回應仍會加上 `private`(0.3.9 以前不會加,#362);`private=True` 的路由本來就會送出 `private`。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(或已棄用的 `SessionMiddleware`)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。會讀取後端的路由第一次繞過時,會以 `WARNING` 等級記錄(見[帶有憑證的請求](#requests-with-credentials))。 +- **請求帶有 `Authorization` 或 Session。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限。設定 `cache_authorized=True`(給包含呼叫者身分的 key builder 使用的明確選項,見[需驗證身分的端點](#authenticated-endpoints))的路由會為這類請求讀寫後端,但回應仍帶有 `private`:項目只在後端依呼叫者區分,CDN 則只以 URL 為鍵(0.3.9 以前會原樣送出裝飾器的標頭,#372)。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。沒有正數 `ttl` 的路由本來就不經過後端,但它對這類請求的回應仍會加上 `private`(0.3.9 以前不會加,#362);`private=True` 的路由本來就會送出 `private`。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(或已棄用的 `SessionMiddleware`)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。會讀取後端的路由第一次繞過時,會以 `WARNING` 等級記錄(見[帶有憑證的請求](#requests-with-credentials))。 - **handler 自己的 `Cache-Control` 含有 `private` 或 `no-store`**(完整指令,不分大小寫)。回應照常送出但不儲存,而且 handler 的標頭會原樣送出,不會被裝飾器的標頭取代。 - **回應設定了 cookie。** 回應照常送出(包含 `Set-Cookie`),但不儲存;它(以及 304)會以 `private` 取代 `public` 送出並保留其他指令,讓下游的共用快取也不會儲存它。 @@ -100,7 +100,7 @@ handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cac 每個請求都送出 `Authorization` 的單頁應用程式,或每位訪客都有 Session 的網站,在只加上 `@cache` 的路由上完全不會命中快取:每個請求都會繞過後端(見上文)。請依 handler 回傳的內容選擇: - **每位使用者得到相同的回應**(商品列表、公開文章):設定 `public=True`。帶有 `Authorization` 或 Session 的請求就會像其他請求一樣讀寫後端。注意 `public=True` 也會把送往下游的標頭改為 `Cache-Control: public, ...`,告訴 CDN 或反向 proxy 即使請求帶有憑證也可以儲存這個回應。只有在這確實成立時才使用它。 -- **回應依使用者而不同**(個人資料、購物車、儀表板):設定 `cache_authorized=True`,並搭配把已驗證的呼叫者身分放進鍵的 `key_builder`,讓每位使用者擁有自己的項目(見[需驗證身分的端點](#authenticated-endpoints))。鍵中沒有身分時,一位使用者的回應會提供給下一位。若不需要伺服器端快取,兩個選項都不要設定(或使用 `private=True`),只讓瀏覽器快取它。 +- **回應依使用者而不同**(個人資料、購物車、儀表板):設定 `cache_authorized=True`,並搭配把已驗證的呼叫者身分放進鍵的 `key_builder`,讓每位使用者擁有自己的項目(見[需驗證身分的端點](#authenticated-endpoints))。鍵中沒有身分時,一位使用者的回應會提供給下一位。回應仍以 `private` 送出,因此下游只有使用者自己的瀏覽器會保留一份。若不需要伺服器端快取,兩個選項都不要設定(或使用 `private=True`),只讓瀏覽器快取它。 為了不讓 0% 的命中率無人察覺,路由第一次因憑證而繞過後端時,會在 `fastapi_cachex.cache` logger 上以 `WARNING` 等級記錄一次,內容包含路由樣板(例如 `'/items/{item_id}'`)、造成繞過的憑證(`Authorization` 標頭、Session 權杖,或不是空的 `request.session` 資料),以及上述兩個選項: @@ -303,7 +303,7 @@ warnings.filterwarnings("ignore", message="cache vary on Cookie") > 若呼叫者以你自己的 Cookie(而非本函式庫的 Session)驗證身分,沒有任何條件會觸發繞過:只加上 `@cache` 會把第一位呼叫者的回應提供給所有人,請改用上面兩種做法之一。 ```python -from fastapi import Request, Response +from fastapi import Request from fastapi_cachex import build_cache_key from fastapi_cachex import cache @@ -327,15 +327,14 @@ 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): - # 沒有 `private` 時,回應會帶著 `Cache-Control: max-age=60` 送出, - # 共用快取(CDN、反向代理)可能會儲存它。對承載身分的標頭設定 Vary, - # 讓這類快取為每位使用者各保留一份。 - response.headers["Vary"] = "Authorization" +async def my_dashboard(user: CurrentUser): + # 對帶有 `Authorization` 或 Session 的請求,以 + # `Cache-Control: private, max-age=60` 送出:只有這個後端與 + # 使用者的瀏覽器會保留一份。 return build_dashboard(user) ``` -依使用者區分的項目要不被應用程式前方的共用快取交給其他使用者,前提是這些快取會遵守該標頭的 `Vary`。若它們不遵守,或身分來自共用快取看不到的地方,請改用做法 1。 +依使用者區分的項目只存在於你的後端。應用程式前方的共用快取(CDN、反向代理)只看得到 URL,因此即使設定了 `cache_authorized`,對帶有 `Authorization` 或 Session 的請求的每個回應仍帶有 `private`。若身分改由你自己的 Cookie 提供,沒有任何條件會把請求標記為帶有憑證,裝飾器的標頭會原樣送出:請設定 `private=True`(做法 1),或在共用快取可能儲存它時自行送出 `Vary: Cookie`。 > [!CAUTION] > key builder 決定了誰能看到誰的資料,因此它讀取的身分必須來自已經驗證過的來源:已檢查權杖中的 claim、你的依賴項解析出的使用者,或驗證中介軟體寫入 `request.state` 的值。 diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index 30e940a..ab10b5d 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -120,9 +120,9 @@ async def me(session=Depends(require_user_session)): - 在已載入的 Session 上清除它(`request.session.clear()`)即為登出:即使資料原本就是空的,也會刪除後端的 Session;Cookie 用戶端還會收到一個使 Cookie 過期的 `Set-Cookie`。同一個請求中在 `clear()` 之後寫入的鍵,會存進一個使用新 ID 的新匿名 Session。 - 以 `del` 或 `pop()` 移除最後一個鍵並不是登出。帶有使用者的 Session 會以空資料儲存;匿名 Session 已無任何內容,會和 `clear()` 一樣被刪除。 - 以寫入 `request.session` 的方式登入時,會沿用請求帶來的 Session ID。Starlette 的中介軟體中 Cookie *就是* Session,因此登入回應會取代任何被植入的 Cookie;這裡的 Cookie 只是指向伺服器端紀錄的名稱,被植入的 Cookie 會跟著受害者一起登入。請以 `await login(request, user)` 登入,它會為 Session 換一個新 ID 並附加使用者(見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login))。 -- 只要存取 `request.session`,就會為了尋找權杖而讀取過的每個請求標頭加入 `Vary`:依 `token_source_priority` 順序檢查的標頭(`header_name`,以及啟用 Bearer 權杖時的 `Authorization`),直到攜帶權杖的那一個為止。只有在沒有任何標頭攜帶權杖時才會讀取 Cookie,因此也只有這時才會加入 `Cookie`。 +- 只要存取 `request.session`,或透過 Session 依賴項(`get_session`、`get_optional_session`,以及建立在它們之上的依賴項,例如 `AuthenticatedSession`)讀取 Session,就會為了尋找權杖而讀取過的每個請求標頭加入 `Vary`:依 `token_source_priority` 順序檢查的標頭(`header_name`,以及啟用 Bearer 權杖時的 `Authorization`),直到攜帶權杖的那一個為止。只有在沒有任何標頭攜帶權杖時才會讀取 Cookie,因此也只有這時才會加入 `Cookie`。 - 帶有 Session 權杖的回應(新建立的 Session、滑動續期、重新產生的 ID),或帶有讓 Session Cookie 失效之 `Set-Cookie` 的回應,一律不可快取。中介軟體會設定 `Cache-Control: private, no-store`,取代路由原本設定的值(包括 `@cache(public=True)` 的路由),並且即使處理函式沒有碰過 `request.session`,也會加入與上一項相同的 `Vary` 名稱。否則 CDN 或反向 proxy 可能存下權杖,再交給下一位訪客。不帶權杖的回應則維持原本的標頭。已棄用的 `SessionMiddleware` 在回應標頭送出權杖時也會這麼做。 -- 對帶有 Session 的請求(中介軟體從任何來源載入的 Session,有沒有使用者都算,或不是空的 `request.session`),`@cache` 不會讀寫後端,並像 `Authorization` 一樣以 `private` 回應。`public=True` 讓路由在各 Session 間共用;`cache_authorized=True` 搭配包含 Session 使用者的 `key_builder` 則依使用者快取。見[需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 +- 對帶有 Session 的請求(中介軟體從任何來源載入的 Session,有沒有使用者都算,或不是空的 `request.session`),`@cache` 不會讀寫後端,並像 `Authorization` 一樣以 `private` 回應。`public=True` 讓路由在各 Session 間共用;`cache_authorized=True` 搭配包含 Session 使用者的 `key_builder` 則依使用者快取,回應仍帶有 `private`。見[需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 Cookie 一律為 `HttpOnly`;`Secure`、`SameSite`、`Domain`、`Path` 與 `Max-Age` 則依 `cookie_*` 設定(`cookie_max_age=None` 或 `0` 時不設 `Max-Age`)。 diff --git a/tests/session/test_cache_authorized_private.py b/tests/session/test_cache_authorized_private.py new file mode 100644 index 0000000..0b44f7e --- /dev/null +++ b/tests/session/test_cache_authorized_private.py @@ -0,0 +1,210 @@ +"""`cache_authorized` answers are private, and a session read sets `Vary` (#372). + +`cache_authorized=True` lets a request with credentials use the backend under +a per-caller key, but its response kept the decorator's header: no `private`, +and no `Vary` unless the handler read `request.session`. A CDN in front of the +app keys on the URL alone, so `max-age=60, must-revalidate` (or any header, for +a session token in `X-Session-Token` or a cookie) let it serve one user's +response to the next. +""" + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient + +from fastapi_cachex import build_cache_key +from fastapi_cachex import cache +from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.dependencies import AuthenticatedSession +from fastapi_cachex.session.dependencies import OptionalSession +from fastapi_cachex.session.manager import SessionManager +from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware +from fastapi_cachex.session.middleware import SessionMiddleware +from fastapi_cachex.session.models import SessionUser + +_AUTH = {"Authorization": "Bearer alice"} + + +def _per_caller(request: Request) -> str: + session = getattr(request.state, "__fastapi_cachex_session", None) + user = session.user.user_id if session and session.user else "" + return build_cache_key(request, request.headers.get("authorization", ""), user) + + +def _vary(response: object) -> set[str]: + header = response.headers.get("vary", "") # type: ignore[attr-defined] + return {name.strip().lower() for name in header.split(",") if name.strip()} + + +@pytest.mark.parametrize( + ("cache_kwargs", "expected"), + [ + ({}, "private, max-age=60"), + ({"must_revalidate": True}, "private, max-age=60, must-revalidate"), + ( + {"stale": "revalidate", "stale_ttl": 30}, + "private, max-age=60, stale-while-revalidate=30", + ), + ], +) +def test_authorization_answers_are_private_on_miss_hit_and_304( + cache_kwargs: dict[str, object], expected: str +) -> None: + app = FastAPI() + calls: list[str] = [] + + @app.get("/me") + @cache(ttl=60, key_builder=_per_caller, cache_authorized=True, **cache_kwargs) # type: ignore[arg-type] + async def me(request: Request) -> dict[str, str]: + calls.append(request.headers["authorization"]) + return {"me": request.headers["authorization"]} + + client = TestClient(app) + miss = client.get("/me", headers=_AUTH) + hit = client.get("/me", headers=_AUTH) + revalidated = client.get( + "/me", headers={**_AUTH, "If-None-Match": miss.headers["etag"]} + ) + + assert calls == ["Bearer alice"] # the backend is still used + assert "age" in hit.headers + assert revalidated.status_code == 304 + for response in (miss, hit, revalidated): + assert response.headers["cache-control"] == expected + + +def test_requests_without_credentials_keep_the_decorator_header() -> None: + app = FastAPI() + + @app.get("/me") + @cache(ttl=60, key_builder=_per_caller, cache_authorized=True) + async def me() -> dict[str, bool]: + return {"ok": True} + + client = TestClient(app) + client.get("/me") + + assert client.get("/me").headers["cache-control"] == "max-age=60" + + +def test_public_routes_keep_public() -> None: + app = FastAPI() + + @app.get("/catalog") + @cache(ttl=60, public=True) + async def catalog() -> dict[str, bool]: + return {"ok": True} + + response = TestClient(app).get("/catalog", headers=_AUTH) + + assert response.headers["cache-control"] == "public, max-age=60" + + +def _session_app(manager: SessionManager, config: SessionConfig) -> FastAPI: + app = FastAPI() + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) + + @app.get("/me") + @cache(ttl=60, key_builder=_per_caller, cache_authorized=True) + async def me(session: AuthenticatedSession) -> dict[str, str]: + assert session.user is not None + return {"user": session.user.user_id} + + @app.get("/greeting") + async def greeting(session: OptionalSession) -> dict[str, str]: + return {"hello": session.user.user_id if session and session.user else "guest"} + + @app.get("/plain") + async def plain() -> dict[str, bool]: + return {"ok": True} + + return app + + +async def _token(manager: SessionManager, user_id: str) -> str: + _session, token = await manager.create_session(user=SessionUser(user_id=user_id)) + return token + + +async def test_header_session_answer_is_private_and_varies( + manager: SessionManager, config: SessionConfig +) -> None: + client = TestClient(_session_app(manager, config)) + token = await _token(manager, "alice") + + for _ in range(2): # miss, then hit + response = client.get("/me", headers={config.header_name: token}) + assert response.json() == {"user": "alice"} + assert response.headers["cache-control"] == "private, max-age=60" + assert config.header_name.lower() in _vary(response) + + +async def test_cookie_session_answer_is_private_and_varies_on_cookie( + manager: SessionManager, config: SessionConfig +) -> None: + client = TestClient(_session_app(manager, config)) + client.cookies.set(config.cookie_name, await _token(manager, "bob")) + + response = client.get("/me") + + assert response.json() == {"user": "bob"} + assert response.headers["cache-control"] == "private, max-age=60" + assert "cookie" in _vary(response) + + +async def test_a_session_dependency_read_varies_without_a_session( + manager: SessionManager, config: SessionConfig +) -> None: + """A guest's answer depends on the token sources as much as a user's does.""" + client = TestClient(_session_app(manager, config)) + + guest = client.get("/greeting") + alice = client.get( + "/greeting", headers={config.header_name: await _token(manager, "alice")} + ) + + assert guest.json() == {"hello": "guest"} + assert alice.json() == {"hello": "alice"} + assert {config.header_name.lower(), "cookie"} <= _vary(guest) + assert config.header_name.lower() in _vary(alice) + + +async def test_routes_that_do_not_read_the_session_do_not_vary( + manager: SessionManager, config: SessionConfig +) -> None: + client = TestClient(_session_app(manager, config)) + + response = client.get( + "/plain", headers={config.header_name: await _token(manager, "alice")} + ) + + assert "vary" not in response.headers + + +async def test_deprecated_middleware_varies_on_a_session_read( + manager: SessionManager, config: SessionConfig +) -> None: + app = FastAPI() + app.add_middleware(SessionMiddleware, session_manager=manager, config=config) + + @app.get("/greeting") + async def greeting(session: OptionalSession) -> dict[str, str]: + return {"hello": session.user.user_id if session and session.user else "guest"} + + @app.get("/plain") + async def plain() -> dict[str, bool]: + return {"ok": True} + + client = TestClient(app) + token = await _token(manager, "alice") + with pytest.warns(DeprecationWarning, match="FastAPICacheXSessionMiddleware"): + response = client.get("/greeting", headers={config.header_name: token}) + + assert response.json() == {"hello": "alice"} + assert config.header_name.lower() in _vary(response) + assert ( + "vary" not in client.get("/plain", headers={config.header_name: token}).headers + ) diff --git a/tests/test_cache_bypass_private.py b/tests/test_cache_bypass_private.py index 8af9cbc..dc47a50 100644 --- a/tests/test_cache_bypass_private.py +++ b/tests/test_cache_bypass_private.py @@ -73,11 +73,11 @@ def test_request_without_credentials_is_unchanged() -> None: ("cache_kwargs", "expected"), [ ({"ttl": 0, "public": True}, "public, max-age=0"), - ({"ttl": 0, "cache_authorized": True}, "max-age=0"), + ({"ttl": 0, "cache_authorized": True}, "private, max-age=0"), ({"ttl": 0, "private": True}, "private, max-age=0"), ], ) -def test_public_opted_in_and_private_routes_are_unchanged( +def test_public_opted_in_and_private_routes( cache_kwargs: dict[str, object], expected: str ) -> None: response = _client(**cache_kwargs).get("/me", headers=_AUTH) diff --git a/tests/test_cache_unshareable.py b/tests/test_cache_unshareable.py index 7d9d9b6..ce4fdf1 100644 --- a/tests/test_cache_unshareable.py +++ b/tests/test_cache_unshareable.py @@ -457,13 +457,13 @@ async def live(): ("public", "cache_authorized", "expected"), [ (True, False, "public, max-age=60"), - (False, True, "max-age=60"), + (False, True, "private, max-age=60"), ], ) -def test_opted_in_authorization_keeps_the_decorator_header( +def test_opted_in_authorization_header( public: bool, cache_authorized: bool, expected: str ): - """Where the bypass is lifted, the header is the decorator's as before.""" + """``public`` keeps the decorator's header; ``cache_authorized`` adds ``private`` (#372).""" app = FastAPI() @app.get("/opted-in")