diff --git a/README.md b/README.md index 0f08982..225971d 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ async def report(cache: AppCache): > [!WARNING] > The default cache key carries no user identity. Cache authenticated endpoints -> with `private=True` or a per-user key builder — see +> with `private=True` or a per-user key builder plus `cache_authorized=True` +> (requests with `Authorization` otherwise bypass the backend) — see > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints). ## Documentation diff --git a/changelog.d/296.security.md b/changelog.d/296.security.md new file mode 100644 index 0000000..d4ea301 --- /dev/null +++ b/changelog.d/296.security.md @@ -0,0 +1,14 @@ +**`@cache` no longer stores responses that belong to one caller.** A request +with an `Authorization` header now bypasses the backend like `private=True` +(RFC 9111 §3.5), unless the route is `public=True` or opts in with the new +`cache_authorized=True`, meant for a `key_builder` that includes the verified +caller's identity. A response whose own `Cache-Control` contains `private` or +`no-store`, or that sets a cookie, is served but not stored, and the handler's +`private`/`no-store` header is no longer replaced by the decorator's. A cookie +response and a bypassed `Authorization` response are sent with `private` in +place of `public` (keeping the other directives; `private, no-cache` on +`no_cache` routes), so a CDN or proxy does not store them either; +`must_revalidate=True` does not lift the bypass. Previously all three were +stored and replayed to every caller, so one user's response could reach +another. The per-user example in HTTP_CACHING.md ("Authenticated endpoints") +now passes `cache_authorized=True`. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index f8f99b9..bb4b542 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -18,8 +18,10 @@ Build the cache key: method|||host|||path|||query_params no-store? ── yes → run the handler, neither read nor write the cache, │ respond with Cache-Control: no-store ↓ no -private? ── yes → run the handler; compare If-None-Match to decide 304 or 200 - │ (the shared backend is neither read nor written) +private, or Authorization without public/cache_authorized? + ── yes → run the handler; compare If-None-Match to decide 304 or 200 + │ (the shared backend is neither read nor written; for + │ Authorization, Cache-Control says private instead of public) ↓ no Read the backend entry ↓ @@ -35,10 +37,15 @@ Cached entry exists, ttl is set, and no-cache is off? ├─ non-2xx (or 206) → return as-is and **do not write** │ (an existing good entry is not overwritten) ├─ streaming/file response → no ETag can be computed; return as-is, do not write + ├─ handler sent Cache-Control private/no-store, or Set-Cookie + │ → set the ETag, return it, **do not write** (an existing + │ entry is left alone; a private/no-store header is kept) └─ regular response → set the ETag; write to the backend only if it differs from the existing entry's ETag ↓ -Attach Cache-Control to the response (non-2xx responses are returned without it) +Attach Cache-Control to the response (non-2xx responses are returned without it, +a handler's own private/no-store Cache-Control is never replaced, and a +Set-Cookie response gets private instead of public) ``` ## Detailed steps @@ -102,8 +109,9 @@ The decorator arguments control both the server-side behaviour and the # Normal caching behaviour @cache(ttl=3600) # Cache for 1 hour (also used as the max-age value) -@cache(public=True) # Allow shared caches +@cache(public=True) # Allow shared caches, also for Authorization requests @cache(private=True) # Private only; never touches the shared backend +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # Authorization requests use the backend @cache(immutable=True) # Content never changes # Header-only directives (they do not change server-side behaviour) @@ -143,8 +151,10 @@ The header value is built once per decorated route: > sends `Cache-Control: private` so the user's own browser can cache the > response, and `If-None-Match` is still compared against freshly rendered > content. -> 2. A custom `key_builder` that includes the identity — when you really do want -> a per-user server-side cache. +> 2. A custom `key_builder` that includes the identity, together with +> `cache_authorized=True` — when you really do want a per-user server-side +> cache. Without `cache_authorized`, a request with an `Authorization` header +> bypasses the backend (see below). > > Take the identity from a trusted source (a verified token claim, a > dependency-injected user object); do not trust unchecked client headers. @@ -182,7 +192,8 @@ if request.method != "GET": if no_store: return await render() # no read, no write -if private or not ttl: +authorized = "authorization" in request.headers and not (public or cache_authorized) +if private or not ttl or authorized: response, etag = await render() # backend neither read nor written return not_modified(...) if etag_matches(client_etag, etag) else response @@ -208,6 +219,8 @@ if not is_cacheable_status(response.status_code): return response # non-2xx: returned as-is, not written if etag is None: return response # streaming/file: no ETag, not written +if marked_private_or_no_store(response) or "set-cookie" in response.headers: + return response # one caller's response: not written if not entry or entry.fingerprint != etag: await backend.set(cache_key, CacheEntry(...), ttl=ttl) return response @@ -221,6 +234,25 @@ return response > `304` and are returned without the decorator's `Cache-Control` header (only > `no_store=True` adds `no-store` to every response). +> [!NOTE] +> **Responses that belong to one caller are never stored.** Following RFC 9111 +> §3.5, a request with an `Authorization` header bypasses the backend (no read, +> no write) unless the route is `public=True` or opts in with +> `cache_authorized=True` (for a `key_builder` that includes the verified +> identity). On a render, a response whose own `Cache-Control` contains +> `private` or `no-store` (whole directive, any case), or that sets a cookie, +> is served but not written. A `private`/`no-store` header from the handler is +> sent unchanged instead of the decorator's. A cookie response, and the answer +> to a bypassed `Authorization` request, are sent (200 or 304) with `private` +> in place of `public` and the decorator's other directives kept (`private, +> no-cache` on a `no_cache` route), so a downstream shared cache does not +> store them either. `must_revalidate=True` does not lift the `Authorization` +> bypass, although RFC 9111 would allow reuse under `must-revalidate`: the +> library requires the explicit opt-in. An entry already stored under the +> key is left alone, and a request that hits it before the handler runs is +> served from it as usual. Each skip is logged at `DEBUG`. Before 0.3.9 such +> responses were stored and replayed to every caller (#296). + ### 4. ETag generation and validation The ETag is computed from the response body and used to detect whether the @@ -386,6 +418,9 @@ lookup. Which backend to pick is covered in [Backends](BACKENDS.md#choosing-a-ba | `no_store=True` | The cache is neither read nor written; the endpoint runs every time | | `no_cache=True` | The endpoint runs every time to recompute the ETag; a match with the client's `If-None-Match` still returns 304, and the cache is updated when the ETag changes | | `private=True` | The **shared backend** is neither read nor written; `Cache-Control: private` is still sent and the ETag is compared against fresh content | +| Request with `Authorization` | The backend is neither read nor written, as with `private=True`, and `Cache-Control` has `private` instead of `public`, unless the route has `public=True` or `cache_authorized=True` (`must_revalidate=True` is not enough) | +| Handler sends `Cache-Control: private`/`no-store` | Returned with the handler's header intact, not written, and any existing entry is left untouched | +| Response sets a cookie | Returned with `private` instead of `public` in `Cache-Control`, not written, and any existing entry is left untouched | | No `ttl` (or `ttl=0`) | The backend is neither read nor written, as with `private=True`; the endpoint runs every time and the ETag is compared against fresh content | | Cache expired (TTL elapsed) | The endpoint runs again; `MemoryBackend` deletes the expired entry in place when it reads it | | Non-2xx or 206 response | Returned as-is, not written, and any existing entry is left untouched | diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index ce5a8c3..b752b32 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: | None (header only). | +| `public` | `public=True` | :white_check_mark: | Requests with `Authorization` still use the backend (otherwise they bypass it). | | `immutable` | `immutable=True` | :white_check_mark: | None (header only). | | `must-revalidate` | `must_revalidate=True` | :white_check_mark: | None (header only). | | `stale-while-revalidate` | `stale="revalidate", stale_ttl=N` | :white_check_mark: | None (header only): the server-side cache never serves stale content. | @@ -89,8 +89,34 @@ Only successful responses are stored. A response the handler *returns* with a non-2xx status (for example `Response(..., status_code=404)`) is passed straight through and never cached, so a transient error cannot replace or poison the last good entry. `206 Partial Content` is excluded as well, since its body is only -meaningful for the `Range` request that produced it. `Set-Cookie` is never -stored or replayed. +meaningful for the `Range` request that produced it. + +A response that belongs to one caller is never stored either (#296): + +- **The request carries `Authorization`.** As RFC 9111 §3.5 requires of a + shared cache, the backend is bypassed, as with `private=True`: nothing is + read or written, the handler runs, and `If-None-Match` is compared against + the fresh render. The response (and a 304) is sent with `private` in place + of `public`, keeping the decorator's other directives (`private, no-cache` + on a `no_cache` route), so a CDN or proxy does not store it either. + `public=True` routes are exempt, and so are routes with + `cache_authorized=True`, the opt-in for a key builder that includes the + caller's identity (see [Authenticated endpoints](#authenticated-endpoints)). + `must_revalidate=True` does not lift the bypass: RFC 9111 would let a shared + cache reuse such a response under `must-revalidate`, but the library + requires an explicit opt-in. +- **The handler's own `Cache-Control` contains `private` or `no-store`** + (as whole directives, in any case). The response is served but not stored, + and the handler's header is sent unchanged instead of the decorator's. +- **The response sets a cookie.** It is served, `Set-Cookie` included, but not + stored, and it (and a 304) is sent with `private` in place of `public`, + keeping the other directives, so a shared cache downstream does not store it + either. + +In the last two cases an entry already stored under the key is left alone, and +a request that finds a valid entry is still answered from it before the +handler runs. A handler's own `private`/`no-store` header always wins, and +`no_store=True` still sends only `no-store`. Each skip is logged at `DEBUG`. A handler that returns plain data instead of a `Response` gets the same treatment it would without `@cache`: the value is validated and filtered by the @@ -184,7 +210,9 @@ HTTP requests. > rendered content. > 2. **A key builder that includes the caller's identity** — use this when you > do want a server-side cache per user. Leave `private` unset: `private=True` -> bypasses the backend, so the key builder would never be used. +> bypasses the backend, so the key builder would never be used. Pass +> `cache_authorized=True` when callers authenticate with an `Authorization` +> header: without it such requests bypass the backend too. ```python from fastapi import Request, Response @@ -217,7 +245,7 @@ def per_user_key(request: Request) -> str: @app.get("/me/dashboard") -@cache(ttl=60, key_builder=per_user_key) +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) async def my_dashboard(user: CurrentUser, response: Response): # Without `private`, the response goes out as `Cache-Control: max-age=60`, # which a shared cache (CDN, reverse proxy) may store. Vary on whatever @@ -245,7 +273,8 @@ something a shared cache cannot see, use option 1 instead. > sending `X-User-Id: ` returns that user's cached response. The key builder runs only when `@cache` reads or writes the backend, so it is not -called for `no_store=True`, `private=True` or routes without a `ttl`. Before 0.3.8 +called for `no_store=True`, `private=True`, routes without a `ttl`, or requests +with `Authorization` on a route without `public=True` or `cache_authorized=True`. Before 0.3.8 it was, only to feed a debug log. Keep it free of side effects. ## Clearing the cache diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 843d154..0ad2aa4 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -352,8 +352,59 @@ def _not_modified( ) -def _with_cache_control(response: Response, cache_control: str) -> Response: - response.headers["Cache-Control"] = cache_control +# Response directives by which the handler says its response belongs to one +# caller (``private``) or must not be kept at all (``no-store``). +_UNSHAREABLE_DIRECTIVES = frozenset( + {DirectiveType.PRIVATE.value, DirectiveType.NO_STORE.value} +) + + +def _marked_unshareable(response: Response) -> bool: + """Whether the handler's own ``Cache-Control`` has ``private`` or ``no-store``. + + Directive names are matched as whole tokens, case-insensitively, across + every ``Cache-Control`` field the response carries. + """ + return any( + directive.split("=", 1)[0].strip().lower() in _UNSHAREABLE_DIRECTIVES + for value in response.headers.getlist("cache-control") + for directive in value.split(",") + ) + + +def _unshareable_reason(response: Response) -> str | None: + """Why a rendered response must not be stored, or None when it may be.""" + if _marked_unshareable(response): + return "response Cache-Control is private or no-store" + if "set-cookie" in response.headers: + return "response sets a cookie" + return None + + +def _cache_control_for( + response: Response, cache_control: str, private_cache_control: str +) -> str: + """The ``Cache-Control`` to send for a response the handler just rendered. + + A handler that marked its response ``private`` or ``no-store`` keeps its own + header; the decorator's would widen what the handler allowed. A response + that sets a cookie gets ``private_cache_control``, so a shared cache in + front of the app does not store it either. + """ + if _marked_unshareable(response): + return ", ".join(response.headers.getlist("cache-control")) + if "set-cookie" in response.headers: + return private_cache_control + return cache_control + + +def _with_cache_control( + response: Response, cache_control: str, private_cache_control: str +) -> Response: + if not _marked_unshareable(response): + response.headers["Cache-Control"] = _cache_control_for( + response, cache_control, private_cache_control + ) return response @@ -494,12 +545,21 @@ def cache( must_revalidate: bool = False, key_builder: CacheKeyBuilder | None = None, fail_open: bool = True, + cache_authorized: bool = False, ) -> Callable[[HandlerCallable], AsyncResponseCallable]: """Cache decorator for FastAPI route handlers. Only GET requests go through the cache; other methods run the handler unchanged. + A response is never stored when the handler marks it ``private`` or + ``no-store`` in its own ``Cache-Control`` (that header is then sent + unchanged instead of the decorator's) or when it sets a cookie. Such a + response is served as rendered, and an entry already stored under its key + is left alone. A response that sets a cookie is sent with ``private`` in + place of ``public`` (the other directives stay), so that a shared cache in + front of the app does not store it either. + Args: ttl: How long, in seconds, a stored response may be served without running the handler. The same value is sent as ``max-age``. @@ -533,6 +593,16 @@ def cache( the cache: a failed read counts as a miss and a failed write 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 + ``public`` is set, and its response is sent with ``private``. + 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 + ``key_builder`` puts the verified caller's identity into the key; + with the default key builder one user's response would be served + to the next. Returns: Decorator function that wraps route handlers with caching logic @@ -617,6 +687,24 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable: immutable=immutable, must_revalidate=must_revalidate, ) + # 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`; + # `no_cache` leaves the scope out of `cache_control`, so it is added. + private_cache_control = ( + f"{DirectiveType.PRIVATE.value}, {cache_control}" + if no_cache + else _build_cache_control( + ttl=ttl, + stale=stale, + stale_ttl=stale_ttl, + no_cache=no_cache, + public=False, + private=True, + immutable=immutable, + must_revalidate=must_revalidate, + ) + ) builder = key_builder or default_key_builder # Without a positive ttl nothing may be served from storage, and a 304 # answered from a stored ETag would be exactly that: it would keep @@ -653,29 +741,65 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response: logger.debug( "no-store active; bypassed cache for path=%s", req.url.path ) - return _with_cache_control(response, _NO_STORE) + # Unconditional: `no-store` is stricter than anything the + # handler may have sent. + response.headers["Cache-Control"] = _NO_STORE + return response client_etag = req.headers.get("if-none-match") + # 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 + ) + if authorized_bypass: + logger.debug( + "Authorization header present; bypassing the backend for path=%s", + req.url.path, + ) + # A private response belongs to exactly one user, so it must never # be read from or written to the shared backend — the default cache # key carries no identity, so a stored copy would be served to the # next caller. The same path serves routes without a positive ttl # (see `bypass_backend`). ETag revalidation still works: it # compares the client's validator against freshly rendered content. - if bypass_backend: + 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. + bypass_cache_control = ( + private_cache_control if authorized_bypass else cache_control + ) 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, cache_control) + return _with_cache_control( + response, bypass_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, response.headers) + return _not_modified( + etag, + _cache_control_for( + response, bypass_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, cache_control) + return _with_cache_control( + response, bypass_cache_control, private_cache_control + ) # Built only here: the branches above never touch the backend, so a # custom key builder would run for nothing. @@ -714,13 +838,19 @@ async def wrapper(*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) + return _with_cache_control( + current_response, cache_control, private_cache_control + ) if _etag_matches(client_etag, current_etag): # For no-cache, compare fresh data with client's ETag logger.debug("304 Not Modified via no-cache; key=%s", cache_key) return _not_modified( - current_etag, cache_control, current_response.headers + current_etag, + _cache_control_for( + current_response, cache_control, private_cache_control + ), + current_response.headers, ) # Compare with cached ETag - if match, return 304 @@ -766,13 +896,22 @@ async def wrapper(*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) + return _with_cache_control( + current_response, cache_control, private_cache_control + ) logger.debug("Cache MISS; computed fresh ETag for key=%s", cache_key) current_response.headers["ETag"] = current_etag + # A response the handler scoped to one caller is served but never + # stored. An entry already under this key is left alone, as for an + # error status: it came from a response that was shareable. + skip_reason = _unshareable_reason(current_response) + if skip_reason is not None: + logger.debug("Not storing key=%s: %s", cache_key, skip_reason) + # Update cache if needed - if not cached_data or cached_data.fingerprint != current_etag: + elif not cached_data or cached_data.fingerprint != current_etag: assert ( current_body is not None ) # guaranteed by early-return guards above @@ -799,7 +938,9 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response: else: logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl) - return _with_cache_control(current_response, cache_control) + return _with_cache_control( + current_response, cache_control, private_cache_control + ) # Update the wrapper with the new signature update_wrapper(wrapper, func) diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 98436a6..97e843c 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -15,8 +15,10 @@ HTTP 請求抵達 no-store? ── 是 → 執行 handler,既不讀取也不寫入快取, │ 回應帶上 Cache-Control: no-store ↓ 否 -private? ── 是 → 執行 handler;比對 If-None-Match 決定回傳 304 或 200 - │ (共用後端既不讀取也不寫入) +private,或帶有 Authorization 且未設定 public/cache_authorized? + ── 是 → 執行 handler;比對 If-None-Match 決定回傳 304 或 200 + │ (共用後端既不讀取也不寫入;Authorization 的情況下 + │ Cache-Control 以 private 取代 public) ↓ 否 讀取後端項目 ↓ @@ -32,10 +34,15 @@ private? ── 是 → 執行 handler;比對 If-None-Match 決定回傳 304 ├─ 非 2xx(或 206)→ 原樣回傳且**不寫入** │ (不會覆寫既有的正常項目) ├─ 串流/檔案回應 → 無法計算 ETag;原樣回傳,不寫入 + ├─ handler 送出 Cache-Control private/no-store,或 Set-Cookie + │ → 設定 ETag 後回傳,**不寫入**(既有項目保持不變; + │ private/no-store 標頭保留原樣) └─ 一般回應 → 設定 ETag;只有與既有項目的 ETag 不同時才寫入後端 ↓ -在回應中附加 Cache-Control(非 2xx 回應回傳時不帶此標頭) +在回應中附加 Cache-Control(非 2xx 回應回傳時不帶此標頭, +handler 自己送出的 private/no-store Cache-Control 永遠不會被取代, +設定 Set-Cookie 的回應則以 private 取代 public) ``` ## 詳細步驟 {#detailed-steps} @@ -87,8 +94,9 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25` # 一般快取行為 @cache(ttl=3600) # 快取 1 小時(也作為 max-age 的值) -@cache(public=True) # 允許共用快取 +@cache(public=True) # 允許共用快取,帶有 Authorization 的請求也一樣 @cache(private=True) # 僅限私有;永遠不接觸共用後端 +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # 帶有 Authorization 的請求也使用後端 @cache(immutable=True) # 內容永不改變 # 只影響標頭的指令(不會改變伺服器端行為) @@ -116,7 +124,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25` > 對於回應內容取決於呼叫者的端點,請擇一處理: > > 1. `private=True`:永遠不讀取或寫入共用後端。它仍會送出 `Cache-Control: private`,讓使用者自己的瀏覽器可以快取回應,而且 `If-None-Match` 仍會與新產生的內容比對。 -> 2. 包含身分的自訂 `key_builder`:當你確實需要以使用者為單位的伺服器端快取時使用。 +> 2. 包含身分的自訂 `key_builder`,並搭配 `cache_authorized=True`:當你確實需要以使用者為單位的伺服器端快取時使用。未設定 `cache_authorized` 時,帶有 `Authorization` 標頭的請求會繞過後端(見下方說明)。 > > 身分請取自可信任的來源(已驗證的權杖 claim、透過依賴注入取得的使用者物件);不要信任未經檢查的用戶端標頭。 @@ -149,7 +157,8 @@ if request.method != "GET": if no_store: return await render() # 不讀取,不寫入 -if private or not ttl: +authorized = "authorization" in request.headers and not (public or cache_authorized) +if private or not ttl or authorized: response, etag = await render() # 既不讀取也不寫入後端 return not_modified(...) if etag_matches(client_etag, etag) else response @@ -175,6 +184,8 @@ if not is_cacheable_status(response.status_code): return response # 非 2xx:原樣回傳,不寫入 if etag is None: return response # 串流/檔案:沒有 ETag,不寫入 +if marked_private_or_no_store(response) or "set-cookie" in response.headers: + return response # 屬於單一呼叫者的回應:不寫入 if not entry or entry.fingerprint != etag: await backend.set(cache_key, CacheEntry(...), ttl=ttl) return response @@ -183,6 +194,9 @@ return response > [!NOTE] > 「非 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)。 + ### 4. ETag 產生與驗證 {#4-etag-generation-and-validation} ETag 由回應內容計算而來,用於偵測內容是否已改變: @@ -332,6 +346,9 @@ 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` 不算) | +| handler 送出 `Cache-Control: private`/`no-store` | 回傳時保留 handler 的標頭,不寫入,既有項目也保持不變 | +| 回應設定了 cookie | 回傳時 `Cache-Control` 以 `private` 取代 `public`,不寫入,既有項目也保持不變 | | 沒有 `ttl`(或 `ttl=0`) | 與 `private=True` 一樣,既不讀取也不寫入後端;端點每次都會執行,並以新產生的內容比對 ETag | | 快取過期(TTL 已到) | 端點會再次執行;`MemoryBackend` 讀取到過期項目時會當場刪除 | | 非 2xx 或 206 回應 | 原樣回傳、不寫入,既有的項目不受影響 | diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 2a7c680..1a6eb60 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: | 無(僅寫入標頭)。 | +| `public` | `public=True` | :white_check_mark: | 帶有 `Authorization` 的請求仍會使用後端(否則會繞過後端)。 | | `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: | 無(僅寫入標頭):伺服器端快取不會回傳過期內容。 | @@ -70,7 +70,15 @@ async def non_store_endpoint(): - **未設定 `ttl`**(`ttl=None`):與 `private=True` 相同,不從後端讀取,也不寫入。每個請求都會執行 handler,只有當 `If-None-Match` 與新產生的回應相符時才回 304,因此內容變更後,舊的 ETag 永遠不會得到 304 - **使用 `ttl=0`**:送出 `max-age=0`,其餘行為與 `ttl=None` 相同。負數、非 `int`(例如 `1.5` 或 `True`)或超過 `MAX_TTL`(見 [TTL 值](BACKENDS.md#ttl-values))的 `ttl`,都會在套用裝飾器時以 `CacheXError` 拒絕 -只有成功的回應會被儲存。handler *回傳* 非 2xx 狀態的回應(例如 `Response(..., status_code=404)`)會原樣傳出、永不快取,因此暫時性的錯誤不會取代或污染上一筆正常的項目。`206 Partial Content` 同樣排除在外,因為它的本文只對產生它的那個 `Range` 請求有意義。`Set-Cookie` 永遠不會被儲存或重播。 +只有成功的回應會被儲存。handler *回傳* 非 2xx 狀態的回應(例如 `Response(..., status_code=404)`)會原樣傳出、永不快取,因此暫時性的錯誤不會取代或污染上一筆正常的項目。`206 Partial Content` 同樣排除在外,因為它的本文只對產生它的那個 `Range` 請求有意義。 + +屬於單一呼叫者的回應同樣不會被儲存(#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` 下重複使用這類回應,但本函式庫要求明確選擇啟用。 +- **handler 自己的 `Cache-Control` 含有 `private` 或 `no-store`**(完整指令,不分大小寫)。回應照常送出但不儲存,而且 handler 的標頭會原樣送出,不會被裝飾器的標頭取代。 +- **回應設定了 cookie。** 回應照常送出(包含 `Set-Cookie`),但不儲存;它(以及 304)會以 `private` 取代 `public` 送出並保留其他指令,讓下游的共用快取也不會儲存它。 + +後兩種情況下,該鍵下已儲存的項目保持不變,而找到有效項目的請求仍會在 handler 執行前由該項目回應。handler 自己的 `private`/`no-store` 標頭一律優先,`no_store=True` 仍只送出 `no-store`。每次略過都會以 `DEBUG` 等級記錄。 handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cache` 時相同:回傳值會經過路由的 response model 驗證與過濾(明確宣告的,或由回傳型別註記推斷,並套用 `response_model_*` 選項),套用路由的 `status_code`,而在注入的 `response: Response` 參數上設定的狀態碼與標頭也會保留。 @@ -128,7 +136,7 @@ Redis 與 Memcached 後端還會在每個鍵前面加上自己的前綴(預設 > 回應內容取決於請求者身分的端點,請擇一處理: > > 1. **`private=True`**:回應永遠不會從共用後端讀取,也不會寫入。`Cache-Control: private` 仍允許使用者自己的瀏覽器快取它,而 `If-None-Match` 重新驗證仍會對新產生的內容運作。 -> 2. **包含呼叫者身分的 key builder**:確實需要依使用者區分的伺服器端快取時使用。不要設定 `private`:`private=True` 會繞過後端,key builder 就永遠不會被使用。 +> 2. **包含呼叫者身分的 key builder**:確實需要依使用者區分的伺服器端快取時使用。不要設定 `private`:`private=True` 會繞過後端,key builder 就永遠不會被使用。呼叫者以 `Authorization` 標頭驗證身分時,請傳入 `cache_authorized=True`:沒有它,這類請求同樣會繞過後端。 ```python from fastapi import Request, Response @@ -160,7 +168,7 @@ def per_user_key(request: Request) -> str: @app.get("/me/dashboard") -@cache(ttl=60, key_builder=per_user_key) +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) async def my_dashboard(user: CurrentUser, response: Response): # 沒有 `private` 時,回應會帶著 `Cache-Control: max-age=60` 送出, # 共用快取(CDN、反向代理)可能會儲存它。對承載身分的標頭設定 Vary, @@ -181,7 +189,7 @@ async def my_dashboard(user: CurrentUser, response: Response): > > 以原始請求標頭組成的鍵等同於水平權限提升:送出 `X-User-Id: ` 就會拿到該使用者的快取回應。 -key builder 只在 `@cache` 讀取或寫入後端時執行,因此 `no_store=True`、`private=True` 或沒有 `ttl` 的路由不會呼叫它。0.3.8 以前它仍會被呼叫,但只用於除錯日誌。請讓它不帶副作用。 +key builder 只在 `@cache` 讀取或寫入後端時執行,因此 `no_store=True`、`private=True`、沒有 `ttl` 的路由,以及路由未設定 `public=True` 或 `cache_authorized=True` 時帶有 `Authorization` 的請求,都不會呼叫它。0.3.8 以前它仍會被呼叫,但只用於除錯日誌。請讓它不帶副作用。 ## 清除快取 {#clearing-the-cache} diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index 9941e3a..90250e2 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,詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 +> 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True`,或依使用者區分的 key builder 搭配 `cache_authorized=True`(否則帶有 `Authorization` 的請求會繞過後端),詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 ## 文件 {#documentation} diff --git a/tests/test_cache_status_headers.py b/tests/test_cache_status_headers.py index ef5fd91..9ef0471 100644 --- a/tests/test_cache_status_headers.py +++ b/tests/test_cache_status_headers.py @@ -123,25 +123,33 @@ async def report(): assert response.text == "body" -def test_set_cookie_is_never_replayed(): - """`Set-Cookie` carries per-user state and must not come back from cache.""" +async def test_set_cookie_is_never_replayed(): + """`Set-Cookie` carries per-user state and must not come back from cache. + + The response that set it is not stored at all (#296): stripping only the + cookie still replayed the body it came with to every other caller. + """ app = FastAPI() client = TestClient(app) + calls = {"n": 0} @app.get("/login-ish") @cache(ttl=60) async def login_ish(): + calls["n"] += 1 return Response( content="ok", media_type="text/plain", headers={"Set-Cookie": "sid=secret; Path=/", "X-Safe": "yes"}, ) - assert "sid=secret" in client.get("/login-ish").headers.get("set-cookie", "") + for _ in range(2): + response = client.get("/login-ish") + assert "sid=secret" in response.headers.get("set-cookie", "") + assert response.headers["X-Safe"] == "yes" - hit = client.get("/login-ish") - assert "set-cookie" not in hit.headers - assert hit.headers["X-Safe"] == "yes" + assert calls["n"] == 2 + assert await BackendProxy.get().get(_key("/login-ish")) is None def test_partial_content_is_not_cached(): diff --git a/tests/test_cache_unshareable.py b/tests/test_cache_unshareable.py new file mode 100644 index 0000000..7d9d9b6 --- /dev/null +++ b/tests/test_cache_unshareable.py @@ -0,0 +1,491 @@ +"""Tests for `@cache` refusing to share responses that belong to one caller (#296). + +A response was written to the shared backend, and replayed to everyone, even +when the handler marked it ``private``/``no-store``, when it set a cookie, or +when the request carried ``Authorization``; the handler's own +``Cache-Control`` was then overwritten with the decorator's. +""" + +import logging + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi import Response +from fastapi.testclient import TestClient + +from fastapi_cachex.cache import cache +from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.types import CACHE_KEY_SEPARATOR + + +def _key(path: str) -> str: + """The key `default_key_builder` produces for a TestClient GET.""" + return f"GET|||testserver|||{path}|||" + + +async def test_issue_repro_private_no_store_with_authorization(): + """The report in #296: bob must not get alice's response.""" + app = FastAPI() + + @app.get("/me") + @cache(ttl=60) + async def me(request: Request, response: Response): + response.headers["Cache-Control"] = "private, no-store" + return {"user": request.headers.get("authorization")} + + client = TestClient(app) + alice = client.get("/me", headers={"Authorization": "Bearer alice"}) + bob = client.get("/me", headers={"Authorization": "Bearer bob"}) + + assert alice.json() == {"user": "Bearer alice"} + assert bob.json() == {"user": "Bearer bob"} + assert bob.headers["Cache-Control"] == "private, no-store" + assert await BackendProxy.get().get_all_keys() == [] + + +@pytest.mark.parametrize( + "handler_cache_control", + ["private", "no-store", "Private, max-age=5", "max-age=5 , NO-STORE"], +) +async def test_response_marked_private_or_no_store_is_not_stored( + handler_cache_control: str, +): + """No `Authorization` involved: the handler's own marking is enough.""" + app = FastAPI() + calls = {"n": 0} + + @app.get("/marked") + @cache(ttl=60) + async def marked(response: Response): + calls["n"] += 1 + response.headers["Cache-Control"] = handler_cache_control + return {"n": calls["n"]} + + client = TestClient(app) + first = client.get("/marked") + second = client.get("/marked") + + assert first.json() == {"n": 1} + assert second.json() == {"n": 2} + # The handler's header is sent as-is, not replaced by `max-age=60`. + assert second.headers["Cache-Control"] == handler_cache_control + assert await BackendProxy.get().get(_key("/marked")) is None + + +@pytest.mark.parametrize( + "handler_cache_control", ["max-age=5", "no-cache", "x-private-hint, public"] +) +async def test_other_directives_are_still_stored(handler_cache_control: str): + """Only whole `private`/`no-store` tokens stop a write.""" + app = FastAPI() + calls = {"n": 0} + + @app.get("/shareable") + @cache(ttl=60) + async def shareable(response: Response): + calls["n"] += 1 + response.headers["Cache-Control"] = handler_cache_control + return {"n": calls["n"]} + + client = TestClient(app) + client.get("/shareable") + hit = client.get("/shareable") + + assert hit.json() == {"n": 1} + assert hit.headers["Cache-Control"] == "max-age=60" + assert await BackendProxy.get().get(_key("/shareable")) is not None + + +async def test_response_setting_a_cookie_is_not_stored(): + """The body that came with a cookie is not replayed to other callers.""" + app = FastAPI() + calls = {"n": 0} + + @app.get("/visit") + @cache(ttl=60) + async def visit(response: Response): + calls["n"] += 1 + response.set_cookie("visitor", f"v{calls['n']}") + return {"visitor": f"v{calls['n']}"} + + client = TestClient(app) + first = client.get("/visit") + second = client.get("/visit") + + assert first.json() == {"visitor": "v1"} + assert second.json() == {"visitor": "v2"} + assert second.cookies["visitor"] == "v2" + assert second.headers["Cache-Control"] == "private, max-age=60" + assert await BackendProxy.get().get(_key("/visit")) is None + + +async def test_authorization_request_bypasses_the_backend(): + """Neither read nor written: not even an anonymous entry is served.""" + app = FastAPI() + + @app.get("/greeting") + @cache(ttl=60) + async def greeting(request: Request): + return {"for": request.headers.get("authorization", "anonymous")} + + client = TestClient(app) + assert client.get("/greeting").json() == {"for": "anonymous"} + stored = await BackendProxy.get().get(_key("/greeting")) + assert stored is not None + + alice = client.get("/greeting", headers={"Authorization": "Bearer alice"}) + bob = client.get("/greeting", headers={"Authorization": "Bearer bob"}) + + assert alice.json() == {"for": "Bearer alice"} + assert bob.json() == {"for": "Bearer bob"} + assert bob.headers["Cache-Control"] == "private, max-age=60" + # The anonymous entry is untouched. + assert await BackendProxy.get().get(_key("/greeting")) == stored + + +def test_authorization_request_still_revalidates(): + """ETag revalidation runs against the fresh render, as for `private`.""" + app = FastAPI() + + @app.get("/doc") + @cache(ttl=60) + async def doc(): + return {"doc": 1} + + client = TestClient(app) + auth = {"Authorization": "Bearer alice"} + etag = client.get("/doc", headers=auth).headers["ETag"] + + revalidated = client.get("/doc", headers={**auth, "If-None-Match": etag}) + + assert revalidated.status_code == 304 + assert revalidated.headers["Cache-Control"] == "private, max-age=60" + + +def test_bypassed_304_keeps_the_handler_cache_control(): + """A 304 for a response the handler marked private repeats its header.""" + app = FastAPI() + + @app.get("/mine") + @cache(ttl=60) + async def mine(response: Response): + response.headers["Cache-Control"] = "private" + return {"mine": True} + + client = TestClient(app) + auth = {"Authorization": "Bearer alice"} + etag = client.get("/mine", headers=auth).headers["ETag"] + + revalidated = client.get("/mine", headers={**auth, "If-None-Match": etag}) + + assert revalidated.status_code == 304 + assert revalidated.headers["Cache-Control"] == "private" + + +async def test_public_route_caches_authorization_requests(): + """RFC 9111 §3.5 lets `public` responses be reused for any caller.""" + app = FastAPI() + calls = {"n": 0} + + @app.get("/catalog") + @cache(ttl=60, public=True) + async def catalog(): + calls["n"] += 1 + return {"n": calls["n"]} + + client = TestClient(app) + client.get("/catalog", headers={"Authorization": "Bearer alice"}) + bob = client.get("/catalog", headers={"Authorization": "Bearer bob"}) + + assert bob.json() == {"n": 1} + assert calls["n"] == 1 + assert await BackendProxy.get().get(_key("/catalog")) is not None + + +async def test_cache_authorized_caches_per_user_entries(): + """The opt-in is for key builders that carry the caller's identity.""" + app = FastAPI() + calls = {"n": 0} + + def per_user_key(request: Request) -> str: + user = request.headers.get("authorization", "anonymous") + return f"{request.url.path}{CACHE_KEY_SEPARATOR}{user}" + + @app.get("/dashboard") + @cache(ttl=60, key_builder=per_user_key, cache_authorized=True) + async def dashboard(request: Request): + calls["n"] += 1 + return {"for": request.headers["authorization"], "n": calls["n"]} + + client = TestClient(app) + alice = {"Authorization": "Bearer alice"} + client.get("/dashboard", headers=alice) + alice_hit = client.get("/dashboard", headers=alice) + bob = client.get("/dashboard", headers={"Authorization": "Bearer bob"}) + + assert alice_hit.json() == {"for": "Bearer alice", "n": 1} + assert bob.json() == {"for": "Bearer bob", "n": 2} + assert await BackendProxy.get().get("/dashboard|||Bearer alice") is not None + + +async def test_unshareable_render_leaves_an_existing_entry_alone(): + """With `no_cache` the handler runs on every request; a private render must + neither overwrite nor evict what an earlier shareable one stored.""" + app = FastAPI() + state = {"private": False} + + @app.get("/feed") + @cache(ttl=60, no_cache=True) + async def feed(response: Response): + if state["private"]: + response.headers["Cache-Control"] = "private" + return {"feed": "personal"} + return {"feed": "shared"} + + client = TestClient(app) + client.get("/feed") + stored = await BackendProxy.get().get(_key("/feed")) + assert stored is not None + + state["private"] = True + response = client.get("/feed") + + assert response.json() == {"feed": "personal"} + assert response.headers["Cache-Control"] == "private" + assert await BackendProxy.get().get(_key("/feed")) == stored + + +def test_no_cache_304_keeps_the_handler_cache_control(): + """The `no_cache` revalidation path repeats the handler's header too.""" + app = FastAPI() + + @app.get("/inbox") + @cache(ttl=60, no_cache=True) + async def inbox(response: Response): + response.headers["Cache-Control"] = "private, no-cache" + return {"inbox": []} + + client = TestClient(app) + etag = client.get("/inbox").headers["ETag"] + + revalidated = client.get("/inbox", headers={"If-None-Match": etag}) + + assert revalidated.status_code == 304 + assert revalidated.headers["Cache-Control"] == "private, no-cache" + + +def test_no_store_decorator_overrides_the_handler_header(): + """`no_store=True` stays the strictest answer, whatever the handler sent.""" + app = FastAPI() + + @app.get("/secret") + @cache(no_store=True) + async def secret(response: Response): + response.headers["Cache-Control"] = "private, max-age=3600" + return {"secret": True} + + response = TestClient(app).get("/secret") + + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize( + ("headers", "cache_control", "set_cookie", "expected"), + [ + ({"Authorization": "Bearer a"}, None, False, "Authorization header present"), + ({}, "private", False, "response Cache-Control is private or no-store"), + ({}, None, True, "response sets a cookie"), + ], +) +def test_each_skip_is_logged_at_debug( + caplog: pytest.LogCaptureFixture, + headers: dict[str, str], + cache_control: str | None, + set_cookie: bool, + expected: str, +): + app = FastAPI() + + @app.get("/logged") + @cache(ttl=60) + async def logged(response: Response): + if cache_control is not None: + response.headers["Cache-Control"] = cache_control + if set_cookie: + response.set_cookie("c", "1") + return {} + + with caplog.at_level(logging.DEBUG, logger="fastapi_cachex.cache"): + TestClient(app).get("/logged", headers=headers) + + assert any( + record.levelno == logging.DEBUG and expected in record.getMessage() + for record in caplog.records + ) + + +async def test_set_cookie_on_a_public_route_is_sent_as_private(): + """A shared cache downstream must not store the cookie response either: + `public` becomes `private`, the other directives stay.""" + app = FastAPI() + + @app.get("/banner") + @cache( + ttl=60, + public=True, + must_revalidate=True, + stale="revalidate", + stale_ttl=30, + immutable=True, + ) + async def banner(response: Response): + response.set_cookie("seen", "1") + return {"banner": True} + + client = TestClient(app) + first = client.get("/banner") + again = client.get("/banner", headers={"If-None-Match": first.headers["ETag"]}) + + expected = ( + "private, max-age=60, must-revalidate, stale-while-revalidate=30, immutable" + ) + assert first.headers["Cache-Control"] == expected + # Not stored: the handler ran again, and its fresh cookie response is a 200. + assert again.status_code == 200 + assert again.headers["Cache-Control"] == expected + assert await BackendProxy.get().get(_key("/banner")) is None + + +def test_set_cookie_304_on_a_bypassed_route_is_private(): + """The bypass path's 304 also carries the private variant.""" + app = FastAPI() + + @app.get("/uncached") + @cache(public=True) + async def uncached(response: Response): + response.set_cookie("seen", "1") + return {"uncached": True} + + client = TestClient(app) + first = client.get("/uncached") + revalidated = client.get( + "/uncached", headers={"If-None-Match": first.headers["ETag"]} + ) + + assert first.headers["Cache-Control"] == "private" + assert revalidated.status_code == 304 + assert revalidated.headers["Cache-Control"] == "private" + + +async def test_set_cookie_on_a_no_cache_route_is_private_no_cache(): + """`no_cache` omits the scope, so `private` is added in front.""" + app = FastAPI() + + @app.get("/ticker") + @cache(ttl=60, no_cache=True, public=True, must_revalidate=True) + async def ticker(response: Response): + response.set_cookie("seen", "1") + return {"ticker": 1} + + client = TestClient(app) + first = client.get("/ticker") + revalidated = client.get( + "/ticker", headers={"If-None-Match": first.headers["ETag"]} + ) + + assert first.headers["Cache-Control"] == "private, no-cache, must-revalidate" + assert revalidated.status_code == 304 + assert revalidated.headers["Cache-Control"] == ( + "private, no-cache, must-revalidate" + ) + assert await BackendProxy.get().get(_key("/ticker")) is None + + +async def test_must_revalidate_does_not_lift_the_authorization_bypass(): + """RFC 9111 §3.5 would allow reuse under `must-revalidate`; the library + requires `public` or `cache_authorized`, and sends `private`.""" + app = FastAPI() + calls = {"n": 0} + + @app.get("/account") + @cache(ttl=60, must_revalidate=True) + async def account(request: Request): + calls["n"] += 1 + return {"for": request.headers["authorization"]} + + client = TestClient(app) + alice = client.get("/account", headers={"Authorization": "Bearer alice"}) + bob = client.get("/account", headers={"Authorization": "Bearer bob"}) + revalidated = client.get( + "/account", + headers={"Authorization": "Bearer bob", "If-None-Match": bob.headers["ETag"]}, + ) + + assert alice.json() == {"for": "Bearer alice"} + assert bob.json() == {"for": "Bearer bob"} + assert bob.headers["Cache-Control"] == "private, max-age=60, must-revalidate" + assert revalidated.status_code == 304 + assert revalidated.headers["Cache-Control"] == ( + "private, max-age=60, must-revalidate" + ) + assert calls["n"] == 3 + assert await BackendProxy.get().get(_key("/account")) is None + + +def test_authorization_on_a_no_cache_route_is_private_no_cache(): + app = FastAPI() + + @app.get("/live") + @cache(ttl=60, no_cache=True) + async def live(): + return {"live": True} + + client = TestClient(app) + auth = {"Authorization": "Bearer alice"} + first = client.get("/live", headers=auth) + revalidated = client.get( + "/live", headers={**auth, "If-None-Match": first.headers["ETag"]} + ) + + assert first.headers["Cache-Control"] == "private, no-cache" + assert revalidated.status_code == 304 + assert revalidated.headers["Cache-Control"] == "private, no-cache" + + +@pytest.mark.parametrize( + ("public", "cache_authorized", "expected"), + [ + (True, False, "public, max-age=60"), + (False, True, "max-age=60"), + ], +) +def test_opted_in_authorization_keeps_the_decorator_header( + public: bool, cache_authorized: bool, expected: str +): + """Where the bypass is lifted, the header is the decorator's as before.""" + app = FastAPI() + + @app.get("/opted-in") + @cache(ttl=60, public=public, cache_authorized=cache_authorized) + async def opted_in(): + return {"ok": True} + + client = TestClient(app) + auth = {"Authorization": "Bearer alice"} + client.get("/opted-in", headers=auth) + hit = client.get("/opted-in", headers=auth) + + assert hit.headers["Cache-Control"] == expected + + +def test_no_store_decorator_wins_over_set_cookie(): + app = FastAPI() + + @app.get("/nothing") + @cache(no_store=True, public=True) + async def nothing(response: Response): + response.set_cookie("c", "1") + return {} + + assert TestClient(app).get("/nothing").headers["Cache-Control"] == "no-store"