From 11a66799d1022af9c0d53b5c38867ea9000cbdd4 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Mon, 28 Sep 2026 00:31:35 +0000 Subject: [PATCH] fix(cache): bypass the backend for requests that arrived with a session Only Authorization bypassed the shared backend, so a plain @cache on a route that read the session served the first visitor's response to every other one: tokens in X-Session-Token or the session cookie, and anonymous sessions, were not recognised. A request now bypasses the backend and is answered with private when the session middleware loaded a session for it or request.session is non-empty; a token that resolves to no session does not count. public=True and cache_authorized=True lift it as before. Closes #319 --- README.md | 2 +- changelog.d/319.security.md | 10 + docs/CACHE_FLOW.md | 21 +- docs/HTTP_CACHING.md | 24 +- docs/SESSION.md | 6 + fastapi_cachex/cache.py | 60 +++-- i18n/zh-TW/docs/CACHE_FLOW.md | 14 +- i18n/zh-TW/docs/HTTP_CACHING.md | 14 +- i18n/zh-TW/docs/SESSION.md | 1 + i18n/zh-TW/docs/index.md | 2 +- tests/session/test_cache_session_bypass.py | 268 +++++++++++++++++++++ 11 files changed, 378 insertions(+), 44 deletions(-) create mode 100644 changelog.d/319.security.md create mode 100644 tests/session/test_cache_session_bypass.py diff --git a/README.md b/README.md index c948fcd..458b37b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/changelog.d/319.security.md b/changelog.d/319.security.md new file mode 100644 index 0000000..7637bbf --- /dev/null +++ b/changelog.d/319.security.md @@ -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. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index ff43963..286e61a 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -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 ↓ @@ -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) @@ -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. @@ -248,13 +249,14 @@ 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` @@ -262,7 +264,8 @@ return response > 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 @@ -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 | diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 0ec5e40..1c4e340 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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. | @@ -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 @@ -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. @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/docs/SESSION.md b/docs/SESSION.md index 251ea3a..48f10cd 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -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. diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 897d88c..6c35006 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -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 " @@ -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 @@ -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: @@ -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}" @@ -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, ) @@ -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 ) diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 32f3d4b..e34e65c 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -15,9 +15,9 @@ HTTP 請求抵達 no-store? ── 是 → 執行 handler,既不讀取也不寫入快取, │ 回應帶上 Cache-Control: no-store ↓ 否 -private,或帶有 Authorization 且未設定 public/cache_authorized? +private,或帶有 Authorization/Session 且未設定 public/cache_authorized? ── 是 → 執行 handler;比對 If-None-Match 決定回傳 304 或 200 - │ (共用後端既不讀取也不寫入;Authorization 的情況下 + │ (共用後端既不讀取也不寫入;Authorization 或 Session 的情況下 │ Cache-Control 以 private 取代 public) ↓ 否 讀取後端項目 @@ -96,9 +96,9 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25` # 一般快取行為 @cache(ttl=3600) # 快取 1 小時(也作為 max-age 的值) -@cache(public=True) # 允許共用快取,帶有 Authorization 的請求也一樣 +@cache(public=True) # 允許共用快取,帶有 Authorization/Session 的請求也一樣 @cache(private=True) # 僅限私有;永遠不接觸共用後端 -@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # 帶有 Authorization 的請求也使用後端 +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # 帶有 Authorization/Session 的請求也使用後端 @cache(immutable=True) # 內容永不改變 # 只影響標頭的指令(不會改變伺服器端行為) @@ -126,7 +126,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25` > 對於回應內容取決於呼叫者的端點,請擇一處理: > > 1. `private=True`:永遠不讀取或寫入共用後端。它仍會送出 `Cache-Control: private`,讓使用者自己的瀏覽器可以快取回應,而且 `If-None-Match` 仍會與新產生的內容比對。 -> 2. 包含身分的自訂 `key_builder`,並搭配 `cache_authorized=True`:當你確實需要以使用者為單位的伺服器端快取時使用。未設定 `cache_authorized` 時,帶有 `Authorization` 標頭的請求會繞過後端(見下方說明)。 +> 2. 包含身分的自訂 `key_builder`,並搭配 `cache_authorized=True`:當你確實需要以使用者為單位的伺服器端快取時使用。未設定 `cache_authorized` 時,帶有 `Authorization` 標頭或 Session 的請求會繞過後端(見下方說明)。 > > 身分請取自可信任的來源(已驗證的權杖 claim、透過依賴注入取得的使用者物件);不要信任未經檢查的用戶端標頭。 @@ -197,7 +197,7 @@ return response > 「非 2xx 不寫入」是刻意的設計:暫時性的錯誤不應抹除最後一次正常的快取回應,也不應在之後被當成 200 重播。`206 Partial Content` 同樣不會快取,因為它的內容只對產生它的那個 `Range` 請求有意義。非 2xx 回應也永遠不會以 `304` 回應,且回傳時不帶裝飾器的 `Cache-Control` 標頭(只有 `no_store=True` 會在每個回應加上 `no-store`)。 > [!NOTE] -> **屬於單一呼叫者的回應永遠不會被儲存。** 依照 RFC 9111 §3.5,帶有 `Authorization` 標頭的請求會繞過後端(不讀取也不寫入),除非路由設定了 `public=True`,或以 `cache_authorized=True` 明確選擇啟用(用於包含已驗證身分的 `key_builder`)。產生回應時,若回應自己的 `Cache-Control` 含有 `private` 或 `no-store`(完整指令,不分大小寫),或回應設定了 cookie,則照常回傳但不寫入。handler 送出的 `private`/`no-store` 標頭會原樣送出,不會被裝飾器的標頭取代。設定 cookie 的回應,以及繞過後端的 `Authorization` 請求的回應(200 或 304),會以 `private` 取代 `public` 送出並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓下游的共用快取也不會儲存它們。`must_revalidate=True` 不會解除 `Authorization` 的繞過;雖然 RFC 9111 允許在 `must-revalidate` 下重複使用,本函式庫仍要求明確選擇啟用。該鍵下已儲存的項目保持不變,而在 handler 執行前就命中該項目的請求仍照常由它回應。每次略過都會以 `DEBUG` 等級記錄。0.3.9 以前這類回應會被儲存並重播給每位呼叫者(#296)。 +> **屬於單一呼叫者的回應永遠不會被儲存。** 依照 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)。 ### 4. ETag 產生與驗證 {#4-etag-generation-and-validation} @@ -348,7 +348,7 @@ async def cleanup_task(): | `no_store=True` | 既不讀取也不寫入快取;端點每次都會執行 | | `no_cache=True` | 端點每次都會執行以重新計算 ETag;與用戶端的 `If-None-Match` 相符時仍回傳 304,ETag 改變時會更新快取 | | `private=True` | **共用後端**既不讀取也不寫入;仍會送出 `Cache-Control: private`,並以新產生的內容比對 ETag | -| 帶有 `Authorization` 的請求 | 與 `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` 不算) | | 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 db7c077..53ebbe3 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -43,7 +43,7 @@ async def non_store_endpoint(): | `no-cache` | `no_cache=True` | :white_check_mark: | 每個請求都執行 handler;回應仍會儲存,`If-None-Match` 相符時回 304。 | | `no-store` | `no_store=True` | :white_check_mark: | 不讀取也不儲存,也不設定 ETag。 | | `private` | `private=True` | :white_check_mark: | 完全不經過後端;每個請求都執行 handler,ETag 重新驗證仍有效。 | -| `public` | `public=True` | :white_check_mark: | 帶有 `Authorization` 的請求仍會使用後端(否則會繞過後端)。 | +| `public` | `public=True` | :white_check_mark: | 帶有 `Authorization` 或 Session 的請求仍會使用後端(否則會繞過後端)。 | | `immutable` | `immutable=True` | :white_check_mark: | 無(僅寫入標頭)。 | | `must-revalidate` | `must_revalidate=True` | :white_check_mark: | 無(僅寫入標頭)。 | | `stale-while-revalidate` | `stale="revalidate", stale_ttl=N` | :white_check_mark: | 無(僅寫入標頭):伺服器端快取不會回傳過期內容。 | @@ -74,7 +74,7 @@ async def non_store_endpoint(): 屬於單一呼叫者的回應同樣不會被儲存(#296): -- **請求帶有 `Authorization`。** 依照 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` 下重複使用這類回應,但本函式庫要求明確選擇啟用。 +- **請求帶有 `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` 下重複使用這類回應,但本函式庫要求明確選擇啟用。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(或已棄用的 `SessionMiddleware`)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。 - **handler 自己的 `Cache-Control` 含有 `private` 或 `no-store`**(完整指令,不分大小寫)。回應照常送出但不儲存,而且 handler 的標頭會原樣送出,不會被裝飾器的標頭取代。 - **回應設定了 cookie。** 回應照常送出(包含 `Set-Cookie`),但不儲存;它(以及 304)會以 `private` 取代 `public` 送出並保留其他指令,讓下游的共用快取也不會儲存它。 @@ -179,7 +179,7 @@ GET|||example.com|||/me|||||||authorization=sha256:3f0a…(64 個十六進位 同一個權杖永遠得到同一個摘要,因此會命中自己的項目;兩個不同的權杖則得到兩筆項目。缺少或空白的憑證標頭不會雜湊,而是與其他空標頭一樣維持 `authorization=`,讓所有匿名呼叫者共用一筆項目,鍵也仍看得出這是匿名的那一筆。其他標頭(包括以其他名稱設定的 Session 標頭)都維持可讀;若你的標頭帶有機密,請透過 `key_builder`(自行雜湊)而不是 `vary` 以它作為鍵。 -`vary=["Authorization"]` 不會解除針對已授權請求的規則(見[需驗證身分的端點](#authenticated-endpoints)):除非路由設定 `public=True` 或傳入 `cache_authorized=True`,帶有 `Authorization` 標頭的請求仍會繞過後端。兩者都沒有設定時,只會儲存匿名的 `authorization=` 那一筆。 +`vary=["Authorization"]` 不會解除針對已授權請求的規則(見[需驗證身分的端點](#authenticated-endpoints)):除非路由設定 `public=True` 或傳入 `cache_authorized=True`,帶有 `Authorization` 標頭(或 Session)的請求仍會繞過後端。兩者都沒有設定時,只會儲存匿名的 `authorization=` 那一筆。 #### `vary=["Cookie"]` 會發出警告 {#varycookie-warns} @@ -188,7 +188,7 @@ GET|||example.com|||/me|||||||authorization=sha256:3f0a…(64 個十六進位 - 讓 `key_builder` 回傳 `build_cache_key(request, <真正重要的那個 Cookie 或使用者 ID>)`,並自行在回應設定 `Vary: Cookie`; - `private=True`,把每位訪客各自的回應交給瀏覽器快取。 -針對單一呼叫者的規則仍然適用:帶有 Cookie 的請求會被快取(只有 `Authorization` 會觸發繞過),但設定 Cookie 的回應一律不會儲存,並以 `private` 送出,因此每個請求都會更新 Session Cookie 的路由什麼也不會存。若你確實需要 `vary=["Cookie"]`,請在匯入定義該路由的模組之前,用標準的過濾器關閉這個警告: +針對單一呼叫者的規則仍然適用:帶有 Cookie 的請求會被快取(只有 `Authorization` 或 Session 會觸發繞過),但設定 Cookie 的回應一律不會儲存,並以 `private` 送出,因此每個請求都會更新 Session Cookie 的路由什麼也不會存。若你確實需要 `vary=["Cookie"]`,請在匯入定義該路由的模組之前,用標準的過濾器關閉這個警告: ```python import warnings @@ -230,7 +230,9 @@ warnings.filterwarnings("ignore", message="cache vary on Cookie") > 回應內容取決於請求者身分的端點,請擇一處理: > > 1. **`private=True`**:回應永遠不會從共用後端讀取,也不會寫入。`Cache-Control: private` 仍允許使用者自己的瀏覽器快取它,而 `If-None-Match` 重新驗證仍會對新產生的內容運作。 -> 2. **包含呼叫者身分的 key builder**:確實需要依使用者區分的伺服器端快取時使用。不要設定 `private`:`private=True` 會繞過後端,key builder 就永遠不會被使用。呼叫者以 `Authorization` 標頭驗證身分時,請傳入 `cache_authorized=True`:沒有它,這類請求同樣會繞過後端。 +> 2. **包含呼叫者身分的 key builder**:確實需要依使用者區分的伺服器端快取時使用。不要設定 `private`:`private=True` 會繞過後端,key builder 就永遠不會被使用。呼叫者以 `Authorization` 標頭或 Session 驗證身分時,請傳入 `cache_authorized=True`:沒有它,這類請求同樣會繞過後端。 +> +> 若呼叫者以你自己的 Cookie(而非本函式庫的 Session)驗證身分,沒有任何條件會觸發繞過:只加上 `@cache` 會把第一位呼叫者的回應提供給所有人,請改用上面兩種做法之一。 ```python from fastapi import Request, Response @@ -277,7 +279,7 @@ async def my_dashboard(user: CurrentUser, response: Response): > > 以原始請求標頭組成的鍵等同於水平權限提升:送出 `X-User-Id: ` 就會拿到該使用者的快取回應。 -key builder 只在 `@cache` 讀取或寫入後端時執行,因此 `no_store=True`、`private=True`、沒有 `ttl` 的路由,以及路由未設定 `public=True` 或 `cache_authorized=True` 時帶有 `Authorization` 的請求,都不會呼叫它。0.3.8 以前它仍會被呼叫,但只用於除錯日誌。請讓它不帶副作用。 +key builder 只在 `@cache` 讀取或寫入後端時執行,因此 `no_store=True`、`private=True`、沒有 `ttl` 的路由,以及路由未設定 `public=True` 或 `cache_authorized=True` 時帶有 `Authorization` 或 Session 的請求,都不會呼叫它。0.3.8 以前它仍會被呼叫,但只用於除錯日誌。請讓它不帶副作用。 ## 清除快取 {#clearing-the-cache} diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index ff5fe24..a92485c 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -349,6 +349,7 @@ async def me(session=Depends(get_session)): - 以寫入 `request.session` 的方式登入時,會沿用請求帶來的 Session ID。Starlette 的中介軟體中 Cookie *就是* Session,因此登入回應會取代任何被植入的 Cookie;這裡的 Cookie 只是指向伺服器端紀錄的名稱,被植入的 Cookie 會跟著受害者一起登入。請在附加使用者之前呼叫 `await rotate_session_id(request)`(見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login))。 - 只要存取 `request.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)。 Cookie 一律為 `HttpOnly`;`Secure`、`SameSite`、`Domain`、`Path` 與 `Max-Age` 則依 `cookie_*` 設定。 diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index bbe37b1..1b75067 100644 --- a/i18n/zh-TW/docs/index.md +++ b/i18n/zh-TW/docs/index.md @@ -71,7 +71,7 @@ async def report(cache: AppCache): ``` > [!WARNING] -> 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True`,或依使用者區分的 key builder 搭配 `cache_authorized=True`(否則帶有 `Authorization` 的請求會繞過後端),詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 +> 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True`,或依使用者區分的 key builder 搭配 `cache_authorized=True`(否則帶有 `Authorization` 或 Session 的請求會繞過後端),詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 ## 文件 {#documentation} diff --git a/tests/session/test_cache_session_bypass.py b/tests/session/test_cache_session_bypass.py new file mode 100644 index 0000000..f4f0be4 --- /dev/null +++ b/tests/session/test_cache_session_bypass.py @@ -0,0 +1,268 @@ +"""`@cache` must not share a response to a request that arrived with a session (#319). + +Only ``Authorization`` bypassed the shared backend, so a session token sent as +the ``X-Session-Token`` header or the session cookie gave one user's cached +response to the next, and an anonymous session's cart to every visitor. +""" + +import logging + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient +from starlette.middleware.sessions import ( + SessionMiddleware as StarletteSessionMiddleware, +) + +from fastapi_cachex import cache +from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.dependencies import AuthenticatedSession +from fastapi_cachex.session.manager import SessionManager +from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware +from fastapi_cachex.session.models import SessionUser +from fastapi_cachex.types import CACHE_KEY_SEPARATOR + +_PRIVATE = "private, max-age=60" + + +def _key(path: str) -> str: + """The key `default_key_builder` produces for a TestClient GET.""" + return f"GET|||testserver|||{path}|||" + + +def _app( + manager: SessionManager, config: SessionConfig, **cache_kwargs: object +) -> FastAPI: + """Routes that answer from the session, under the session middleware.""" + app = FastAPI() + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) + + @app.get("/whoami") + @cache(ttl=60, **cache_kwargs) # type: ignore[arg-type] + async def whoami(session: AuthenticatedSession) -> dict[str, str]: + assert session.user is not None + return {"user": session.user.user_id} + + @app.get("/cart") + @cache(ttl=60) + async def cart(request: Request) -> dict[str, list[str]]: + return {"cart": request.session.get("cart", [])} + + @app.get("/add") + async def add(request: Request, item: str) -> dict[str, bool]: + request.session["cart"] = [*request.session.get("cart", []), item] + 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_issue_repro_session_header( + manager: SessionManager, config: SessionConfig +) -> None: + """The report in #319: bob must not get alice's /whoami.""" + client = TestClient(_app(manager, config)) + alice = {config.header_name: await _token(manager, "alice")} + bob = {config.header_name: await _token(manager, "bob")} + + assert client.get("/whoami", headers=alice).json() == {"user": "alice"} + response = client.get("/whoami", headers=bob) + + assert response.json() == {"user": "bob"} + assert response.headers["Cache-Control"] == _PRIVATE + assert await BackendProxy.get().get(_key("/whoami")) is None + + +async def test_session_cookie_bypasses_the_backend( + manager: SessionManager, config: SessionConfig +) -> None: + app = _app(manager, config) + alice = TestClient( + app, cookies={config.cookie_name: await _token(manager, "alice")} + ) + bob = TestClient(app, cookies={config.cookie_name: await _token(manager, "bob")}) + + assert alice.get("/whoami").json() == {"user": "alice"} + response = bob.get("/whoami") + + assert response.json() == {"user": "bob"} + assert response.headers["Cache-Control"] == _PRIVATE + assert await BackendProxy.get().get(_key("/whoami")) is None + + +async def test_bearer_session_bypasses_the_backend( + manager: SessionManager, config: SessionConfig +) -> None: + """Already covered by the `Authorization` rule; kept so it stays that way.""" + client = TestClient(_app(manager, config)) + alice = {"Authorization": f"Bearer {await _token(manager, 'alice')}"} + bob = {"Authorization": f"Bearer {await _token(manager, 'bob')}"} + + client.get("/whoami", headers=alice) + + assert client.get("/whoami", headers=bob).json() == {"user": "bob"} + + +async def test_anonymous_session_is_not_shared( + manager: SessionManager, config: SessionConfig +) -> None: + """A cart in an anonymous session reaches neither bob nor a new visitor.""" + app = _app(manager, config) + alice = TestClient(app) + alice.get("/add", params={"item": "apple"}) + + assert alice.get("/cart").json() == {"cart": ["apple"]} + assert TestClient(app).get("/cart").json() == {"cart": []} + bob = TestClient(app) + bob.get("/add", params={"item": "pear"}) + assert bob.get("/cart").json() == {"cart": ["pear"]} + + +async def test_request_without_a_session_is_still_cached( + manager: SessionManager, config: SessionConfig +) -> None: + app = _app(manager, config) + calls = {"n": 0} + + @app.get("/news") + @cache(ttl=60) + async def news() -> dict[str, int]: + calls["n"] += 1 + return {"n": calls["n"]} + + client = TestClient(app) + client.get("/news") + response = client.get("/news") + + assert response.json() == {"n": 1} + assert response.headers["Cache-Control"] == "max-age=60" + + +@pytest.mark.parametrize("transport", ["header", "cookie"]) +async def test_token_that_resolves_to_no_session_does_not_bypass( + manager: SessionManager, config: SessionConfig, transport: str +) -> None: + """Random or expired tokens must not keep requests away from the cache.""" + app = _app(manager, config) + calls = {"n": 0} + + @app.get("/news") + @cache(ttl=60) + async def news() -> dict[str, int]: + calls["n"] += 1 + return {"n": calls["n"]} + + TestClient(app).get("/news") + if transport == "header": + response = TestClient(app).get("/news", headers={config.header_name: "bogus"}) + else: + response = TestClient(app, cookies={config.cookie_name: "bogus"}).get("/news") + + assert response.json() == {"n": 1} + assert calls["n"] == 1 + + +async def test_public_route_is_shared_across_sessions( + manager: SessionManager, config: SessionConfig +) -> None: + """As for `Authorization`: `public` says the response suits every caller.""" + app = _app(manager, config) + calls = {"n": 0} + + @app.get("/catalog") + @cache(ttl=60, public=True) + async def catalog() -> dict[str, int]: + calls["n"] += 1 + return {"n": calls["n"]} + + client = TestClient(app) + client.get("/catalog", headers={config.header_name: await _token(manager, "alice")}) + response = client.get( + "/catalog", headers={config.header_name: await _token(manager, "bob")} + ) + + assert response.json() == {"n": 1} + assert response.headers["Cache-Control"] == "public, max-age=60" + + +async def test_cache_authorized_caches_per_session_entries( + manager: SessionManager, config: SessionConfig +) -> None: + def per_user_key(request: Request) -> str: + session = request.state.__fastapi_cachex_session + return f"{request.url.path}{CACHE_KEY_SEPARATOR}{session.user.user_id}" + + client = TestClient( + _app(manager, config, key_builder=per_user_key, cache_authorized=True) + ) + alice = {config.header_name: await _token(manager, "alice")} + bob = {config.header_name: await _token(manager, "bob")} + + client.get("/whoami", headers=alice) + + assert client.get("/whoami", headers=bob).json() == {"user": "bob"} + assert await BackendProxy.get().get("/whoami|||alice") is not None + assert await BackendProxy.get().get("/whoami|||bob") is not None + + +def test_bypass_is_logged( + manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture +) -> None: + app = _app(manager, config) + client = TestClient(app) + client.get("/add", params={"item": "apple"}) + + with caplog.at_level(logging.DEBUG, logger="fastapi_cachex.cache"): + client.get("/cart") + + assert "Session present; bypassing the backend for path=/cart" in caplog.text + + +def _starlette_app() -> tuple[FastAPI, dict[str, int]]: + app = FastAPI() + app.add_middleware(StarletteSessionMiddleware, secret_key="s" * 32) + calls = {"n": 0} + + @app.get("/cart") + @cache(ttl=60) + async def cart(request: Request) -> dict[str, object]: + calls["n"] += 1 + return {"cart": request.session.get("cart", []), "n": calls["n"]} + + @app.get("/add") + async def add(request: Request, item: str) -> dict[str, bool]: + request.session["cart"] = [item] + return {"ok": True} + + return app, calls + + +async def test_starlette_session_with_data_bypasses_the_backend() -> None: + """Any session middleware: a non-empty `request.session` is per visitor.""" + app, _ = _starlette_app() + alice = TestClient(app) + alice.get("/add", params={"item": "apple"}) + + response = alice.get("/cart") + + assert response.json()["cart"] == ["apple"] + assert response.headers["Cache-Control"] == _PRIVATE + assert await BackendProxy.get().get(_key("/cart")) is None + + +def test_empty_starlette_session_is_still_cached() -> None: + app, calls = _starlette_app() + client = TestClient(app) + + client.get("/cart") + client.get("/cart") + + assert calls["n"] == 1