Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion changelog.d/268.added.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hex digest>`,
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
Expand Down
59 changes: 59 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, <the one cookie or the
user id that matters>)`, 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
Expand Down
65 changes: 58 additions & 7 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, <that cookie or the user id>), 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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:<hex digest>`` 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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion fastapi_cachex/session/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 將查詢字串正規化。

Expand Down
29 changes: 29 additions & 0 deletions i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
>
Expand Down
Loading
Loading