From b881769c54dac3cc7e5db8424f7b969046ccaff0 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sun, 27 Sep 2026 17:59:12 +0000 Subject: [PATCH] fix(cache): hash credential header values that vary puts into the key Authorization, Proxy-Authorization, Cookie and X-Session-Token are keyed on sha256: of the value; missing or empty stays name=. vary=['Cookie'] emits a UserWarning at decoration, since every visitor gets an entry. --- changelog.d/268.added.md | 8 +- docs/CACHE_FLOW.md | 3 + docs/HTTP_CACHING.md | 59 ++++++++++ fastapi_cachex/cache.py | 65 +++++++++-- fastapi_cachex/session/config.py | 6 +- i18n/zh-TW/docs/CACHE_FLOW.md | 2 +- i18n/zh-TW/docs/HTTP_CACHING.md | 29 +++++ tests/test_cache_vary.py | 187 ++++++++++++++++++++++++++++++- 8 files changed, 348 insertions(+), 11 deletions(-) diff --git a/changelog.d/268.added.md b/changelog.d/268.added.md index f92a597..4be1b92 100644 --- a/changelog.d/268.added.md +++ b/changelog.d/268.added.md @@ -7,6 +7,12 @@ the `Vary` header of every GET response, 200 or 304, stored or not, without repeating names already there and leaving `Vary: *` alone. `vary` is checked when the decorator is applied; a bare string such as `vary="Accept"` is rejected. `invalidate()` takes the same `vary` list, and `clear_path()` clears -every variant of a path. Routes without `vary` keep their keys. The header +every variant of a path. Routes without `vary` keep their keys. The credential +headers `Authorization`, `Proxy-Authorization`, `Cookie` and `X-Session-Token` +are keyed on `sha256:` plus the SHA-256 of their value, so no token or session +cookie shows up in `get_all_keys()`, the monitoring routes or the Redis and +Memcached keyspace; missing or empty, they stay `name=` so anonymous callers +share one entry. Listing `Cookie` emits a `UserWarning` when the decorator is +applied, since every visitor then gets an entry of their own. The header values are client-controlled, so HTTP_CACHING.md shows how to normalise them with a `key_builder` and `build_cache_key` instead. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index dc23c9c..ff43963 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -90,6 +90,9 @@ A custom `key_builder` can add components after the query string with appends one `name=value` component per listed request header after whatever the key builder returns, and adds the names to the response's `Vary` header (see [Varying on request headers](HTTP_CACHING.md#varying-on-request-headers)). +For the credential headers `Authorization`, `Proxy-Authorization`, `Cookie` +and `X-Session-Token` a non-empty value is written as `sha256:`, +so no token appears in the key. Query parameters are joined in the order the request sent them (`str(request.query_params)`) and are **not sorted**, so `?page=1&limit=10` and diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 8c807d3..0ec5e40 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -268,6 +268,65 @@ the response already lists (in any case) is not repeated, and a response with `vary="Accept"` is rejected when the decorator is applied, as are empty names, `*` and anything that is not a valid field name. +#### Credential headers are hashed + +The key is not secret: it is listed by `get_all_keys()`, shown by the +`/cached-records` and `/cached-hits` monitoring routes, and stored as-is in the +Redis or Memcached keyspace. So for the headers that carry credentials, +`Authorization`, `Proxy-Authorization`, `Cookie` and `X-Session-Token` (the +session subsystem's default `header_name`), matched in any case, the component +holds the full hex SHA-256 of the value (trimmed and joined as above) instead +of the value: + +``` +GET|||example.com|||/me|||||||authorization=sha256:3f0a…(64 hex digits) +``` + +The same token always gives the same digest, so it hits its own entry, and two +tokens give two entries. A missing or empty credential header is not hashed: +it stays `authorization=`, like any other empty header, so every anonymous +caller shares one entry and the key still shows that it is the anonymous one. +Every other header, including a session header configured under another name, +stays readable; if yours carries a secret, key on it through a `key_builder` +(hashing it yourself) rather than `vary`. + +`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 +`public=True` or passes `cache_authorized=True`. Without either, only the +anonymous `authorization=` entry is ever stored. + +#### `vary=["Cookie"]` warns + +Listing `Cookie` keys on the whole `Cookie` header, so every visitor with a +distinct set of cookies (a session ID, an analytics ID, a consent flag) gets +their own entry, and a new one whenever any cookie changes: the number of +entries grows with the number of visitors. `@cache` emits a `UserWarning` when +the decorator is applied, pointing at your `@cache(...)` line. Usually one of +these is what you want instead: + +- a `key_builder` returning `build_cache_key(request, )`, plus `Vary: Cookie` set on the response yourself; +- `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 +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 +route is imported: + +```python +import warnings + +warnings.filterwarnings("ignore", message="cache vary on Cookie") +``` + +`Authorization` and `X-Session-Token` in `vary` do not warn: they are also one +entry per caller, but that is what `vary` with `cache_authorized=True` is for, +and a caller keeps the same token across many requests, unlike an arbitrary +bundle of cookies. + > [!WARNING] > **Every distinct header value is its own entry, and the values come from the > client.** `Accept-Language: de`, `de-DE`, `de-DE,de;q=0.9` and every other diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index ab1f37a..897d88c 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -3,6 +3,7 @@ import hashlib import inspect import logging +import warnings from collections.abc import Awaitable from collections.abc import Callable from collections.abc import Mapping @@ -39,6 +40,7 @@ from .headers import add_vary from .proxy import BackendProxy from .proxy import get_backend_or_fallback +from .session.config import DEFAULT_SESSION_HEADER_NAME from .types import CACHE_KEY_SEPARATOR from .types import CacheEntry from .types import CacheKeyBuilder @@ -180,18 +182,51 @@ def _validate_vary(vary: Sequence[str] | None) -> list[str]: return list(names.values()) +# Request headers whose values are credentials: ``vary`` keys on a digest of +# the value instead of the value, so the key (shown by ``get_all_keys()``, the +# monitoring routes and the Redis/Memcached keyspace) never holds a token. +_HASHED_VARY_HEADERS = frozenset( + { + "authorization", + "proxy-authorization", + "cookie", + DEFAULT_SESSION_HEADER_NAME.lower(), + } +) +_HASHED_VARY_MARKER = "sha256:" + + def _vary_components(request: Request, names: Sequence[str]) -> list[str]: """The key components for ``@cache(vary=names)``: ``name=value`` each. The name is lower-cased and the value trimmed; repeated header lines are joined with ``,`` as RFC 9110 §5.3 allows, and a missing header gives an - empty value, the same as an empty one. + empty value, the same as an empty one. For a credential header + (``Authorization``, ``Proxy-Authorization``, ``Cookie`` and the session + subsystem's ``X-Session-Token``) a non-empty value is replaced by + ``sha256:`` and the full hex SHA-256 of the joined value; an empty or + missing one stays ``name=``, so anonymous requests share one entry. """ - return [ - f"{name.lower()}=" - + ",".join(value.strip() for value in request.headers.getlist(name)) - for name in names - ] + components = [] + for name in names: + lowered = name.lower() + value = ",".join(line.strip() for line in request.headers.getlist(name)) + if value and lowered in _HASHED_VARY_HEADERS: + digest = hashlib.sha256(value.encode("utf-8", "surrogatepass")) + value = _HASHED_VARY_MARKER + digest.hexdigest() + components.append(f"{lowered}={value}") + return components + + +_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 " + "the number of visitors (and a new entry is made whenever any cookie " + "changes). Key on the one value that matters instead, with a key_builder " + "returning build_cache_key(request, ), or use " + "private=True. To keep vary=['Cookie'], silence this with " + "warnings.filterwarnings('ignore', message='cache vary on Cookie')." +) def default_key_builder(request: Request) -> str: @@ -232,6 +267,9 @@ async def invalidate( vary: The target route's ``vary`` names, if any. Only the variant selected by ``request``'s own values for those headers is deleted; ``clear_path()`` clears every variant of a path. + Credential headers (``Authorization``, ``Cookie``, ...) are + hashed exactly as ``@cache`` hashes them, so pass a request + carrying the same header value. Returns: True if a cache entry existed and was deleted, False otherwise. @@ -752,7 +790,16 @@ def cache( header of every response to a GET request, unless it already lists them or ``*``. Values are client-controlled: each listed header multiplies the number of entries, so normalise them in a - ``key_builder`` when only a few values matter. + ``key_builder`` when only a few values matter. The credential + headers ``Authorization``, ``Proxy-Authorization``, ``Cookie`` + and ``X-Session-Token`` are keyed on ``sha256:`` of + the value rather than the value, so no token or session cookie + 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 + that sets a cookie is still not stored. Returns: Decorator function that wraps route handlers with caching logic @@ -788,6 +835,10 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable: msg = f"ttl must be at most {MAX_TTL} seconds" raise CacheXError(msg) vary_names = _validate_vary(vary) + if any(name.lower() == "cookie" for name in vary_names): + # stacklevel=2: the caller applying the decorator, i.e. the line + # of the user's @cache(...). + warnings.warn(_COOKIE_VARY_WARNING, UserWarning, stacklevel=2) # Analyze the original function's signature sig: Signature = inspect.signature(func) diff --git a/fastapi_cachex/session/config.py b/fastapi_cachex/session/config.py index 8ebb7af..5605a15 100644 --- a/fastapi_cachex/session/config.py +++ b/fastapi_cachex/session/config.py @@ -17,6 +17,10 @@ IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address +# The default ``SessionConfig.header_name``; ``@cache(vary=[...])`` also hashes +# this header's value like ``Authorization`` and ``Cookie``. +DEFAULT_SESSION_HEADER_NAME = "X-Session-Token" + @lru_cache(maxsize=256) def _parse_network(entry: str) -> IPNetwork | None: @@ -98,7 +102,7 @@ class SessionConfig(BaseModel): description="Token serialization format: 'simple' (default) or 'jwt'", ) header_name: str = Field( - default="X-Session-Token", + default=DEFAULT_SESSION_HEADER_NAME, description="Custom header name for session token", ) use_bearer_token: bool = Field( diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 107356e..32f3d4b 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -74,7 +74,7 @@ cache_key = CACHE_KEY_SEPARATOR.join( host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`(`fastapi_cachex/types.py` 中的 `escape_key_component`)。兩者都來自用戶端,其中若出現未編碼的 `|||`,各段就會錯位,使某個請求的快取鍵可能與另一個請求相同。查詢字串本來就經過 URL 編碼。監控路由顯示時會再解碼。 -自訂的 `key_builder` 可以用 `build_cache_key(request, *components)` 在查詢字串之後加入其他段;這些段以同樣方式編碼,`clear_path()` 也仍會比對路徑(見 [HTTP 快取](HTTP_CACHING.md#adding-components-to-the-key)中的「在鍵中加入其他段」)。`@cache(vary=[...])` 會在 key builder 回傳的鍵之後,為每個列出的請求標頭附加一個 `name=value` 段,並把這些名稱加入回應的 `Vary` 標頭(見 [HTTP 快取](HTTP_CACHING.md#varying-on-request-headers)中的「依請求標頭區分」)。 +自訂的 `key_builder` 可以用 `build_cache_key(request, *components)` 在查詢字串之後加入其他段;這些段以同樣方式編碼,`clear_path()` 也仍會比對路徑(見 [HTTP 快取](HTTP_CACHING.md#adding-components-to-the-key)中的「在鍵中加入其他段」)。`@cache(vary=[...])` 會在 key builder 回傳的鍵之後,為每個列出的請求標頭附加一個 `name=value` 段,並把這些名稱加入回應的 `Vary` 標頭(見 [HTTP 快取](HTTP_CACHING.md#varying-on-request-headers)中的「依請求標頭區分」)。對於憑證標頭 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`,非空的值會寫成 `sha256:<十六進位摘要>`,因此鍵中不會出現任何權杖。 查詢參數依請求送出的順序串接(`str(request.query_params)`),**不會排序**,因此 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩個不同的快取項目。若希望兩者視為同一個,請傳入自訂的 `key_builder` 將查詢字串正規化。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index b4fef18..db7c077 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -169,6 +169,35 @@ async def greeting(request: Request): `vary` 必須是由標頭欄位名稱組成的 list(或 tuple)。套用裝飾器時,會拒絕 `vary="Accept"` 這類單一字串,以及空名稱、`*` 與任何不是有效欄位名稱的值。 +#### 憑證標頭會雜湊 {#credential-headers-are-hashed} + +快取鍵並非機密:`get_all_keys()` 會列出它、`/cached-records` 與 `/cached-hits` 監控路由會顯示它,Redis 或 Memcached 的鍵空間也會原樣儲存它。因此對於攜帶憑證的標頭,也就是 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`(Session 子系統預設的 `header_name`),不分大小寫,該段存放的是值(依上述方式去除空白並串接)的完整十六進位 SHA-256,而不是值本身: + +``` +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=["Cookie"]` 會發出警告 {#varycookie-warns} + +列出 `Cookie` 會以整個 `Cookie` 標頭作為鍵,因此每位帶有不同 Cookie 組合(Session ID、分析用 ID、同意旗標)的訪客都會有自己的項目,而且任何 Cookie 改變時又會多出一筆:項目數量隨訪客人數增長。套用裝飾器時,`@cache` 會發出指向你 `@cache(...)` 那一行的 `UserWarning`。通常你真正需要的是下列其中之一: + +- 讓 `key_builder` 回傳 `build_cache_key(request, <真正重要的那個 Cookie 或使用者 ID>)`,並自行在回應設定 `Vary: Cookie`; +- `private=True`,把每位訪客各自的回應交給瀏覽器快取。 + +針對單一呼叫者的規則仍然適用:帶有 Cookie 的請求會被快取(只有 `Authorization` 會觸發繞過),但設定 Cookie 的回應一律不會儲存,並以 `private` 送出,因此每個請求都會更新 Session Cookie 的路由什麼也不會存。若你確實需要 `vary=["Cookie"]`,請在匯入定義該路由的模組之前,用標準的過濾器關閉這個警告: + +```python +import warnings + +warnings.filterwarnings("ignore", message="cache vary on Cookie") +``` + +`vary` 中的 `Authorization` 與 `X-Session-Token` 不會發出警告:它們同樣是每位呼叫者一筆項目,但這正是 `vary` 搭配 `cache_authorized=True` 的用途,而且呼叫者在多個請求間會沿用同一個權杖,不像任意組合的 Cookie。 + > [!WARNING] > **每個不同的標頭值都是一筆獨立的項目,而這些值來自用戶端。** `Accept-Language: de`、`de-DE`、`de-DE,de;q=0.9` 以及其他寫法都是不同的鍵,用戶端可以在每個請求送出新的值來塞滿後端。每多列一個標頭,項目數量就會成倍增加。若只有少數幾個值有意義,請改在 `key_builder` 中正規化,並自行把該標頭加入 `Vary`: > diff --git a/tests/test_cache_vary.py b/tests/test_cache_vary.py index 848a011..4aed291 100644 --- a/tests/test_cache_vary.py +++ b/tests/test_cache_vary.py @@ -1,5 +1,7 @@ -"""``@cache(vary=[...])`` keys on request headers and sends ``Vary`` (#268).""" +"""``@cache(vary=[...])`` keys on request headers and sends ``Vary`` (#268, #312).""" +import hashlib +import warnings from typing import Any import pytest @@ -8,8 +10,11 @@ from fastapi import Response from fastapi.testclient import TestClient +from fastapi_cachex import SessionConfig +from fastapi_cachex import add_routes from fastapi_cachex import build_cache_key from fastapi_cachex import invalidate +from fastapi_cachex.cache import _HASHED_VARY_HEADERS from fastapi_cachex.cache import cache from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.proxy import BackendProxy @@ -271,3 +276,183 @@ async def test_invalid_vary_is_rejected_by_invalidate() -> None: with pytest.raises(CacheXError, match="vary"): await invalidate(request, vary="Accept") + + +# --- Credential headers are hashed (#312) --- + +TOKEN_A = "Bearer secret-token-a" +TOKEN_B = "Bearer secret-token-b" +ME_KEY = "GET|||testserver|||/me|||" + + +def _digest(value: str) -> str: + return "sha256:" + hashlib.sha256(value.encode()).hexdigest() + + +def _credential_app( + vary: list[str], **cache_kwargs: Any +) -> tuple[TestClient, dict[str, int]]: + app = FastAPI() + calls = {"n": 0} + + @app.get("/me") + @cache(ttl=60, vary=vary, **cache_kwargs) + async def me() -> dict[str, int]: + calls["n"] += 1 + return {"n": calls["n"]} + + @app.post("/me") + async def reset(request: Request) -> dict[str, bool]: + request.scope["method"] = "GET" + return {"deleted": await invalidate(request, vary=vary)} + + add_routes(app, prefix="/cache", dependencies=[]) + return TestClient(app), calls + + +async def test_authorization_value_is_hashed_everywhere_the_key_shows() -> None: + client, calls = _credential_app(["Authorization"], cache_authorized=True) + + first = client.get("/me", headers={"Authorization": TOKEN_A}) + again = client.get("/me", headers={"Authorization": TOKEN_A}) + other = client.get("/me", headers={"Authorization": TOKEN_B}) + + assert first.json() == again.json() == {"n": 1} + assert other.json() == {"n": 2} + assert calls["n"] == 2 + keys = await BackendProxy.get().get_all_keys() + assert sorted(keys) == sorted( + [ + f"{ME_KEY}|||authorization={_digest(TOKEN_A)}", + f"{ME_KEY}|||authorization={_digest(TOKEN_B)}", + ] + ) + records = client.get("/cache/cached-records").text + hits = client.get("/cache/cached-hits").text + for shown in (" ".join(keys), records, hits): + assert "secret-token" not in shown + assert _digest(TOKEN_A) in shown + + +@pytest.mark.parametrize( + ("name", "header"), + [ + pytest.param("Authorization", "authorization", id="authorization"), + pytest.param("AUTHORIZATION", "authorization", id="upper-case"), + pytest.param("proxy-authorization", "proxy-authorization", id="proxy"), + pytest.param("X-Session-Token", "x-session-token", id="session-token"), + ], +) +async def test_credential_headers_are_hashed_in_any_case( + name: str, header: str +) -> None: + client, _ = _credential_app([name], public=True) + + client.get("/me", headers={header.upper(): " s3cret "}) + + assert await BackendProxy.get().get_all_keys() == [ + f"{ME_KEY}|||{header}={_digest('s3cret')}" + ] + + +def test_session_header_default_is_hashed() -> None: + default = SessionConfig.model_fields["header_name"].default + + assert default.lower() in _HASHED_VARY_HEADERS + + +async def test_cookie_is_hashed_with_repeated_lines_joined() -> None: + with pytest.warns(UserWarning, match="cache vary on Cookie"): + client, _ = _credential_app(["Cookie"]) + + client.get("/me", headers=[("Cookie", "sid=abc"), ("Cookie", " theme=dark ")]) + + assert await BackendProxy.get().get_all_keys() == [ + f"{ME_KEY}|||cookie={_digest('sid=abc,theme=dark')}" + ] + + +async def test_missing_or_empty_credential_header_gives_the_empty_component() -> None: + client, calls = _credential_app(["Authorization"], cache_authorized=True) + + client.get("/me") + client.get("/me", headers={"Authorization": " "}) + + assert calls["n"] == 1 + assert await BackendProxy.get().get_all_keys() == [f"{ME_KEY}|||authorization="] + + +async def test_authorization_in_vary_still_bypasses_without_opt_in() -> None: + client, calls = _credential_app(["Authorization"]) + + client.get("/me", headers={"Authorization": TOKEN_A}) + client.get("/me", headers={"Authorization": TOKEN_A}) + client.get("/me") + + assert calls["n"] == 3 + assert await BackendProxy.get().get_all_keys() == [f"{ME_KEY}|||authorization="] + + +async def test_non_credential_headers_stay_readable() -> None: + client, _ = _credential_app(["X-Tenant", "Authorization"], cache_authorized=True) + + client.get("/me", headers={"X-Tenant": "acme", "Authorization": TOKEN_A}) + + assert await BackendProxy.get().get_all_keys() == [ + f"{ME_KEY}|||x-tenant=acme|||authorization={_digest(TOKEN_A)}" + ] + + +async def test_invalidate_deletes_the_hashed_variant() -> None: + client, _ = _credential_app(["Authorization"], cache_authorized=True) + client.get("/me", headers={"Authorization": TOKEN_A}) + client.get("/me", headers={"Authorization": TOKEN_B}) + + deleted = client.post("/me", headers={"Authorization": TOKEN_A}).json() + again = client.post("/me", headers={"Authorization": TOKEN_A}).json() + + assert deleted == {"deleted": True} + assert again == {"deleted": False} + assert await BackendProxy.get().get_all_keys() == [ + f"{ME_KEY}|||authorization={_digest(TOKEN_B)}" + ] + + +# --- vary=["Cookie"] warns at decoration (#312) --- + + +@pytest.mark.parametrize("name", ["Cookie", "cookie", "COOKIE"]) +def test_vary_on_cookie_warns_at_the_callers_line(name: str) -> None: + with pytest.warns(UserWarning, match="cache vary on Cookie") as record: + + @cache(ttl=60, vary=["Accept-Language", name]) + async def handler() -> dict[str, str]: + return {} + + [warning] = record + assert warning.filename == __file__ + assert "build_cache_key" in str(warning.message) + assert "private=True" in str(warning.message) + + +def test_documented_filter_silences_the_cookie_warning() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + warnings.filterwarnings("ignore", message="cache vary on Cookie") + + cache(ttl=60, vary=["Cookie"])(lambda: None) + + +@pytest.mark.parametrize( + "vary", + [ + pytest.param(["Accept-Language"], id="accept-language"), + pytest.param(["Authorization", "X-Session-Token"], id="credentials"), + pytest.param(["X-Cookie-Consent"], id="cookie-lookalike"), + ], +) +def test_other_vary_names_do_not_warn(vary: list[str]) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + + cache(ttl=60, vary=vary)(lambda: None)