diff --git a/changelog.d/269.changed.md b/changelog.d/269.changed.md new file mode 100644 index 0000000..a73395d --- /dev/null +++ b/changelog.d/269.changed.md @@ -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`. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 40a6efb..e6fd7d8 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -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:`, so the query part of +the key stays bounded. The key format keeps each dimension cached independently: diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 83d51a7..dd0692b 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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 @@ -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 @@ -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:` 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 diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index a18282c..72433c9 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -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. diff --git a/fastapi_cachex/cache_key.py b/fastapi_cachex/cache_key.py index d30bae5..1d9facd 100644 --- a/fastapi_cachex/cache_key.py +++ b/fastapi_cachex/cache_key.py @@ -5,6 +5,7 @@ and nowhere else. """ +import hashlib from dataclasses import dataclass from operator import itemgetter from typing import ClassVar @@ -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("*?[]\\") @@ -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: @@ -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()``:: diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 6e16505..a0f1274 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -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:<十六進位摘要>`,讓鍵中查詢的部分維持有限長度。 這個快取鍵格式讓每個維度各自獨立快取: diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 5c36b84..c8bd774 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -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`。 @@ -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` 產生的鍵也一樣。 diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index f297094..3147228 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -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)` 讀取鍵的各段。 diff --git a/tests/test_query_hash.py b/tests/test_query_hash.py new file mode 100644 index 0000000..818c347 --- /dev/null +++ b/tests/test_query_hash.py @@ -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 == {}