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
5 changes: 5 additions & 0 deletions changelog.d/269.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**A query string over 200 bytes is stored in the HTTP cache key as its
SHA-256 digest.** The key holds `sha256:` and the 64-digit hex digest instead
of the query, so a client cannot make the query part of the key arbitrarily
long. The path stays readable, so `clear_path()` still finds such entries, and
the monitoring routes show the digest as `query_params`.
4 changes: 3 additions & 1 deletion docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ Query parameters are joined in the order the request sent them
(`str(request.query_params)`) and are **not sorted** by default, so
`?page=1&limit=10` and `?limit=10&page=1` are two separate cache entries. To
treat them as one, set `@cache(sort_query=True)`, which orders the parameters
by name first (see [Cache keys](HTTP_CACHING.md#cache-keys)).
by name first (see [Cache keys](HTTP_CACHING.md#cache-keys)). A query longer
than 200 bytes is then replaced by `sha256:<hex digest>`, so the query part of
the key stays bounded.

The key format keeps each dimension cached independently:

Expand Down
17 changes: 14 additions & 3 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,16 @@ query order as sent can keep `sort_query=False`.
Pass `sort_query=True` to `invalidate()` for such a route as well (see
[Invalidating a single cached route](#invalidating-a-single-cached-route)).

A query string longer than 200 bytes, as encoded in the key, is stored as
`sha256:` and its 64-digit hex digest instead, so a client cannot make the
query part of the key arbitrarily long. (Memcached still hashes a whole key over
250 bytes, prefix included; a query just under the threshold with a long host
or path can get there.) The digest is taken after sorting, so `sort_query` still
merges reordered long queries. The path stays readable, so `clear_path()` still
finds the entry (with `include_params=True`, since the query is not empty), and
the monitoring routes show the digest as `query_params`. A query as sent never
looks like one: the key writes `:` as `%3A`.

The host and path come from the client, so `|` and `%` in them are percent-encoded
(`%7C` and `%25`). A `Host` header or path containing `|` therefore cannot shift
the components and make one request's key equal another's. The query string is
Expand All @@ -352,8 +362,8 @@ proxy keeps its port. A request without a `Host` header uses `unknown`.

Otherwise the host is still whatever the client sends. Unless a reverse proxy
or load balancer in front of the app already rejects unknown hosts, add
Starlette's `TrustedHostMiddleware`, so a forged `Host` gets a `400` instead of filling the
cache with entries no one else will request:
Starlette's `TrustedHostMiddleware`, so a forged `Host` gets a `400` instead of
filling the cache with entries no one else will request:

```python
from starlette.middleware.trustedhost import TrustedHostMiddleware
Expand Down Expand Up @@ -747,7 +757,8 @@ add_routes(
```

- `GET {prefix}/cached-hits` — every cached entry split into method, host, path
and query, with its ETag and expiry, plus counts of valid and expired entries
and query (`sha256:<hex>` for a query over 200 bytes), with its ETag and
expiry, plus counts of valid and expired entries
and the distinct cached paths. It does not count hits.
- `GET {prefix}/cached-records` — every cached record with its size, expiry,
`media_type` (the stored response's media type, `null` if it had none) and a
Expand Down
2 changes: 1 addition & 1 deletion docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ CacheManagerProxy.set(CacheManager(lock=True))
- The separator becomes a single `|` (`CACHE_KEY_SEPARATOR`).
- Keys start with the format tag `http:v2|` (`CacheKey.FORMAT_TAG`), so the next format change can remove old keys by pattern: `clear_pattern("http:v2|*")` removes every key of this format.
- The host is normalised: lower-cased, and an empty port or the scheme's default one (`:80` on http, `:443` on https) dropped.
- A long query string (over about 200 bytes) is stored as `sha256:` and its hex digest; the path stays readable.
- A query string over 200 bytes (as encoded in the key) is stored as `sha256:` and its hex digest; the path stays readable, and the monitoring routes show the digest.
- Query parameters are sorted by name: `sort_query` (opt-in since 0.3.9) defaults to `True` in `@cache`, `build_cache_key()` and `invalidate()`, so `?b=2&a=1` and `?a=1&b=2` share one entry.
- One public `CacheKey` type builds, encodes and parses keys. `CACHE_KEY_MIN_PARTS`, `CACHE_KEY_MAX_SPLIT` and `CACHE_KEY_MAX_PARTS` are removed from `fastapi_cachex.routes`; read a key's components with `CacheKey.parse(key)` instead.

Expand Down
28 changes: 24 additions & 4 deletions fastapi_cachex/cache_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
and nowhere else.
"""

import hashlib
from dataclasses import dataclass
from operator import itemgetter
from typing import ClassVar
Expand All @@ -23,6 +24,13 @@
# The port a scheme implies when the Host header names none.
_DEFAULT_PORTS = {"http": "80", "https": "443", "ws": "80", "wss": "443"}

# A query component longer than this many bytes is stored as its digest.
_QUERY_HASH_THRESHOLD = 200

# Marks a hashed query. A query as sent never starts with it: the encoding
# writes ``:`` as ``%3A``.
_QUERY_HASH_PREFIX = "sha256:"

# Characters that are live in a Redis glob pattern.
_GLOB_SPECIAL = frozenset("*?[]\\")

Expand All @@ -41,10 +49,20 @@ def _query_component(request: Request, sort_query: bool) -> str:
pairs by name before encoding them the same way, so only the order of
differently named parameters changes: repeated values of one name keep
their relative order, and an already sorted query gives the unsorted key.

A query longer than 200 bytes (``_QUERY_HASH_THRESHOLD``) is replaced by
``sha256:`` and its full hex digest, so a client cannot make the query
part of the key arbitrarily long; the digest is taken after sorting, so ``sort_query``
still merges reordered long queries.
"""
if not sort_query:
return str(request.query_params)
return urlencode(sorted(request.query_params.multi_items(), key=itemgetter(0)))
if sort_query:
query = urlencode(sorted(request.query_params.multi_items(), key=itemgetter(0)))
else:
query = str(request.query_params)
encoded = query.encode()
if len(encoded) > _QUERY_HASH_THRESHOLD:
return _QUERY_HASH_PREFIX + hashlib.sha256(encoded).hexdigest()
return query


def _host_component(request: Request) -> str:
Expand Down Expand Up @@ -103,7 +121,9 @@ class CacheKey:
``extra`` item hold the plain text and are percent-encoded by ``to_str()``
(see ``escape_key_component``), so a client-controlled value cannot
contain the separator; a method token may contain ``|``. ``query`` is
kept URL-encoded, as it appears in the key.
kept URL-encoded, as it appears in the key; ``from_request()`` stores a
query longer than 200 bytes as ``sha256:`` and its hex digest instead,
which no query as sent can look like.

Build one from a request with ``from_request()`` and turn a stored key
back into one with ``parse()``::
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 @@ -87,7 +87,7 @@ cache_key = "|".join(

自訂的 `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` 是兩個不同的快取項目。若希望兩者視為同一個,請設定 `@cache(sort_query=True)`,先依名稱排序參數(見 [HTTP 快取](HTTP_CACHING.md#cache-keys)中的「快取鍵」)。
查詢參數依請求送出的順序串接(`str(request.query_params)`),預設**不會排序**,因此 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩個不同的快取項目。若希望兩者視為同一個,請設定 `@cache(sort_query=True)`,先依名稱排序參數(見 [HTTP 快取](HTTP_CACHING.md#cache-keys)中的「快取鍵」)。超過 200 位元組的查詢接著會改寫為 `sha256:<十六進位摘要>`,讓鍵中查詢的部分維持有限長度。

這個快取鍵格式讓每個維度各自獨立快取:

Expand Down
4 changes: 3 additions & 1 deletion i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ async def search(q: str, limit: int = 10):

`sort_query` 只套用於預設的 key builder。與自訂的 `key_builder` 一起使用時,套用裝飾器就會拋出 `CacheXError`;請改在 builder 中呼叫 `build_cache_key(request, ..., sort_query=True)`。對這樣的路由呼叫 `invalidate()` 時也要傳入 `sort_query=True`(見[使單一快取路由失效](#invalidating-a-single-cached-route))。

在鍵中編碼後超過 200 位元組的查詢字串,會改以 `sha256:` 加上 64 個十六進位字元的摘要儲存,因此用戶端無法讓鍵中查詢的部分無限變長。(超過 250 位元組的整個鍵,含前綴,Memcached 仍會整個雜湊;略低於門檻的查詢配上較長的 host 或路徑仍可能超過。)摘要在排序之後計算,因此 `sort_query` 仍會合併順序不同的長查詢。路徑維持可讀,所以 `clear_path()` 仍找得到該項目(需帶上 `include_params=True`,因為查詢不是空的),監控路由則以 `query_params` 顯示這個摘要。用戶端送出的查詢不可能看起來像摘要:鍵中的 `:` 會寫成 `%3A`。

host 與路徑來自用戶端,因此其中的 `|` 與 `%` 會以百分比編碼寫入(`%7C` 與 `%25`)。含有 `|` 的 `Host` 標頭或路徑因此無法讓各段錯位,使某個請求的快取鍵與另一個請求相同。查詢字串本來就經過 URL 編碼。`clear_path()` 接受應用程式看到的路徑(`request.url.path`),並以同樣方式編碼;`clear_pattern()` 比對的是儲存的快取鍵,所以在模式中要把 `|` 寫成 `%7C`。0.3.8 之前兩者都照原樣儲存,因此升級後,host 或路徑含有 `|` 或 `%` 的項目會重新快取一次。

host 會先經過正規化,讓同一個來源的各種寫法共用同一筆項目:轉為小寫(主機名稱不分大小寫),並去除空的連接埠或該 scheme 的預設連接埠(http 為 `:80`,https 為 `:443`)。在 http 上,`Example.com`、`example.com:80` 與 `example.com` 是同一個鍵 `example.com`;`example.com:8080` 保留連接埠,IPv6 位址則保留方括號(`[::1]:8000`)。scheme 以應用程式看到的為準:在終止 TLS 的代理之後,除非套用了代理的標頭(例如 `uvicorn --proxy-headers`),否則 scheme 是 `http`,因此這類代理送來的 `Host: example.com:443` 會保留連接埠。沒有 `Host` 標頭的請求使用 `unknown`。
Expand Down Expand Up @@ -448,7 +450,7 @@ add_routes(
)
```

- `GET {prefix}/cached-hits`:列出每筆快取項目,拆分為方法、主機、路徑與查詢,附上 ETag 與到期時間,另外統計有效與已過期的項目數,以及不重複的快取路徑。它不會計算命中次數。
- `GET {prefix}/cached-hits`:列出每筆快取項目,拆分為方法、主機、路徑與查詢(超過 200 位元組的查詢為 `sha256:<十六進位>`),附上 ETag 與到期時間,另外統計有效與已過期的項目數,以及不重複的快取路徑。它不會計算命中次數。
- `GET {prefix}/cached-records`:列出每筆快取紀錄的大小、到期時間、`media_type`(儲存的回應的媒體類型,沒有時為 `null`),以及快取內容前 100 個位元組的預覽。設定 `include_content_preview=False` 時,`content_preview` 為 `null`,不會有任何回應本文離開伺服器;鍵、大小與到期時間仍會回報。`content_type` 一律是 `"bytes"`,只為相容而保留;請改讀 `media_type`。

兩個路由都只列出路由項目(格式為 `http:v2|method|host|path|query` 的鍵);`CacheManager`、Session、state 與鎖的鍵都會略過,未使用 `build_cache_key()` 的 `key_builder` 產生的鍵也一樣。
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ CacheManagerProxy.set(CacheManager(lock=True))
- 分隔符號改為單一的 `|`(`CACHE_KEY_SEPARATOR`)。
- 鍵以格式標籤 `http:v2|`(`CacheKey.FORMAT_TAG`)開頭,讓下一次格式變更可以用模式移除舊鍵:`clear_pattern("http:v2|*")` 會移除這個格式的所有鍵。
- host 會正規化:轉為小寫,並去除空的連接埠或該 scheme 的預設連接埠(http 為 `:80`,https 為 `:443`)。
- 過長的查詢字串(約超過 200 位元組)會以 `sha256:` 加上十六進位摘要儲存;路徑仍保持可讀。
- 超過 200 位元組(以鍵中的編碼計算)的查詢字串會以 `sha256:` 加上十六進位摘要儲存;路徑仍保持可讀,監控路由則顯示這個摘要。
- 查詢參數會依名稱排序:`sort_query`(0.3.9 起可選用)在 `@cache`、`build_cache_key()` 與 `invalidate()` 中預設為 `True`,因此 `?b=2&a=1` 與 `?a=1&b=2` 共用同一筆項目。
- 由單一、公開的 `CacheKey` 型別負責建立、編碼與解析鍵。`fastapi_cachex.routes` 中的 `CACHE_KEY_MIN_PARTS`、`CACHE_KEY_MAX_SPLIT` 與 `CACHE_KEY_MAX_PARTS` 已移除;請改用 `CacheKey.parse(key)` 讀取鍵的各段。

Expand Down
169 changes: 169 additions & 0 deletions tests/test_query_hash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""A long query string is stored in the key as its SHA-256 digest (#269)."""

import hashlib

from fastapi import FastAPI
from fastapi import Request
from fastapi.testclient import TestClient

from fastapi_cachex import CacheKey
from fastapi_cachex import add_routes
from fastapi_cachex import build_cache_key
from fastapi_cachex import cache
from fastapi_cachex import invalidate
from fastapi_cachex.backends import MemoryBackend
from fastapi_cachex.proxy import BackendProxy
from fastapi_cachex.types import CacheEntry

ENTRY = CacheEntry("e", b"v")


def _request(query: str) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/p",
"raw_path": b"/p",
"query_string": query.encode(),
"headers": [(b"host", b"h")],
}
)


def _query(length: int) -> str:
"""A query that is ``length`` bytes once encoded."""
return "q=" + "a" * (length - 2)


def _digest(query: str) -> str:
return "sha256:" + hashlib.sha256(query.encode()).hexdigest()


def test_a_query_of_200_bytes_is_kept() -> None:
query = _query(200)

assert CacheKey.from_request(_request(query)).query == query


def test_a_longer_query_is_stored_as_its_digest() -> None:
query = _query(201)

key = build_cache_key(_request(query))

assert key == f"http:v2|GET|h|/p|{_digest(query)}"
assert CacheKey.parse(key) == CacheKey("GET", "h", "/p", _digest(query))


def test_the_digest_covers_the_encoded_query() -> None:
"""Length and digest are of the encoded query, not the decoded values."""
encoded = "q=" + "%C3%A9" * 40 # 242 bytes; the decoded value is 40 chars

assert CacheKey.from_request(_request(encoded)).query == _digest(encoded)


def test_the_digest_is_taken_after_sorting() -> None:
forward = "a=1&" + _query(250)
reverse = _query(250) + "&a=1"

sorted_keys = {
build_cache_key(_request(q), sort_query=True) for q in (forward, reverse)
}
unsorted_keys = {build_cache_key(_request(q)) for q in (forward, reverse)}

assert len(sorted_keys) == 1
assert len(unsorted_keys) == 2


def test_extra_components_follow_the_digest() -> None:
query = _query(201)

key = build_cache_key(_request(query), "tenant", 7)

assert key == f"http:v2|GET|h|/p|{_digest(query)}|tenant|7"
assert CacheKey.parse(key) == CacheKey(
"GET", "h", "/p", _digest(query), ("tenant", "7")
)


def test_a_query_cannot_pose_as_a_digest() -> None:
"""``:`` is percent-encoded, so a sent query never starts with ``sha256:``."""
digest = _digest(_query(201))

assert CacheKey.from_request(_request(digest)).query != digest


def _app(backend: MemoryBackend) -> tuple[FastAPI, list[int]]:
app = FastAPI()
BackendProxy.set(backend)
calls: list[int] = []

@app.get("/p")
@cache(ttl=60)
async def endpoint() -> dict[str, int]:
calls.append(1)
return {"calls": len(calls)}

add_routes(app, dependencies=[])
return app, calls


def test_long_queries_are_cached_apart_and_hit_again() -> None:
backend = MemoryBackend()
app, calls = _app(backend)
client = TestClient(app)
first, second = _query(300), "q=" + "b" * 298

client.get(f"/p?{first}")
client.get(f"/p?{second}")
client.get(f"/p?{first}")

assert len(calls) == 2
assert sorted(backend.cache) == sorted(
f"http:v2|GET|testserver|/p|{_digest(q)}" for q in (first, second)
)


def test_monitoring_shows_the_digest() -> None:
backend = MemoryBackend()
app, _ = _app(backend)
client = TestClient(app)
query = _query(300)

client.get(f"/p?{query}")
[record] = client.get("/cached-records").json()["cached_records"]

assert record["path"] == "/p"
assert record["query_params"] == _digest(query)


async def test_clear_path_counts_a_hashed_query_as_a_query() -> None:
backend = MemoryBackend()
BackendProxy.set(backend)
await backend.set(build_cache_key(_request(_query(300))), ENTRY)

assert await backend.clear_path("/p") == 0
assert await backend.clear_path("/p", include_params=True) == 1


async def test_invalidate_finds_a_hashed_entry() -> None:
backend = MemoryBackend()
BackendProxy.set(backend)
request = _request(_query(300))
await backend.set(build_cache_key(request), ENTRY)

assert await invalidate(request) is True
assert backend.cache == {}


async def test_sorted_invalidate_finds_a_reordered_long_query() -> None:
backend = MemoryBackend()
BackendProxy.set(backend)
stored = _request("a=1&" + _query(250))
await backend.set(build_cache_key(stored, sort_query=True), ENTRY)

reordered = _request(_query(250) + "&a=1")

assert await invalidate(reordered) is False
assert await invalidate(reordered, sort_query=True) is True
assert backend.cache == {}
Loading