diff --git a/changelog.d/265.changed.md b/changelog.d/265.changed.md new file mode 100644 index 0000000..58e8e85 --- /dev/null +++ b/changelog.d/265.changed.md @@ -0,0 +1,6 @@ +**The host in an HTTP cache key is normalised.** It is lower-cased, and an +empty port or the scheme's default one (`:80` for http, `:443` for https) is +dropped, so `Example.com`, `example.com:80` and `example.com` share one entry +instead of three. IPv6 literals keep their brackets. Keys for hosts written +with upper case or a default port change, so those entries are cached afresh +once. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 34756bf..40a6efb 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -72,7 +72,7 @@ cache_key = "|".join( [ CacheKey.FORMAT_TAG, # "http:v2" escape_key_component(request.method), - escape_key_component(request.headers.get("host", "unknown")), + escape_key_component(host), # Host header, normalised (see below) escape_key_component(request.url.path), query, ] @@ -120,7 +120,8 @@ The key format keeps each dimension cached independently: - **Method isolation**: GET and POST do not share a cache (and currently only GET enters the cache flow at all) -- **Host isolation**: `example.com` and `api.example.com` are cached separately +- **Host isolation**: `example.com` and `api.example.com` are cached separately; + `Example.com` and `example.com:80` (on http) are `example.com` - **Path isolation**: each endpoint has its own entries - **Query parameter isolation**: different query parameters on the same endpoint are cached separately diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 9591884..83d51a7 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -340,9 +340,19 @@ stored key, so write `%7C` there for a `|`. Before 0.3.8 both were stored as sen so after upgrading, entries for a host or path containing `|` or `%` are cached afresh once. -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 +The host is normalised first, so every spelling of one origin shares an entry: +it is lower-cased (hostnames are case-insensitive), and an empty port or the +scheme's default one (`:80` for http, `:443` for https) is dropped. +`Example.com`, `example.com:80` and `example.com` on http are one key, +`example.com`; `example.com:8080` keeps its port, and an IPv6 literal keeps its +brackets (`[::1]:8000`). The scheme is the one the app sees: behind a proxy that +terminates TLS it is `http` unless the proxy's headers are applied (for +example `uvicorn --proxy-headers`), so a `Host: example.com:443` from such a +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: ```python diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index b4e4709..a18282c 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -252,7 +252,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 the scheme's default port (`:80`, `:443`) dropped. +- 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. - 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. @@ -264,7 +264,7 @@ After: http:v2|GET|example.com|/users/1|page=2 What to change: -- `clear_pattern()` patterns that spell out the separator (`"GET|||*|||/users/*"`) need rewriting. `clear_path()` and `invalidate()` build the key themselves and need nothing. +- `clear_pattern()` patterns that spell out the separator (`"GET|||*|||/users/*"`) need rewriting, and so do patterns that name a host in upper case or with a default port. `clear_path()` and `invalidate()` build the key themselves and need nothing. - A custom `key_builder` that calls `build_cache_key()` follows automatically. One that builds the key itself (joining with `CACHE_KEY_SEPARATOR` or hard-coding `|||`) still caches and still works with `invalidate()` and `clear_pattern()`, but its keys lack the `http:v2` tag, so `clear_path()` no longer finds them and the monitoring routes no longer list them. Switch it to `build_cache_key(request, *components)` to keep both. - A handler whose response depends on the order of the query string as sent, such as a self or pagination link copied from `request.url` or a signature over the raw query, should set `@cache(sort_query=False)`. Otherwise the first caller's order is cached and served to callers who sent another. `sort_query=False` works on 0.3.9 already. - Entries written by 0.3.x are not read by 0.4.0. They expire on their TTL; on Redis and memory you can remove them right after the upgrade with `await backend.clear_pattern("*|||*")`. That pattern matches any key containing `|||`, so check first that none of your own keys (a `CacheManager` key, say) does. Memcached cannot enumerate keys, so there they just expire. diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index b61608d..751a998 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -83,7 +83,9 @@ def build_cache_key( def per_user_key(request: Request) -> str: return build_cache_key(request, request.state.user_id) - ``|`` and ``%`` in the host, the path and every extra component are + The host is the ``Host`` header lower-cased, without an empty or default + port (``:80`` on http, ``:443`` on https), or ``unknown`` when there is + none. ``|`` and ``%`` in the host, the path and every extra component are percent-encoded (see ``escape_key_component``), so none of them can contain the separator and make one request's key equal another's. The query string is already URL-encoded and never contains ``|``. diff --git a/fastapi_cachex/cache_key.py b/fastapi_cachex/cache_key.py index c6f355b..d30bae5 100644 --- a/fastapi_cachex/cache_key.py +++ b/fastapi_cachex/cache_key.py @@ -20,6 +20,9 @@ # Format tag, method, host, path and query: a key never has fewer components. _MIN_PARTS = 5 +# The port a scheme implies when the Host header names none. +_DEFAULT_PORTS = {"http": "80", "https": "443", "ws": "80", "wss": "443"} + # Characters that are live in a Redis glob pattern. _GLOB_SPECIAL = frozenset("*?[]\\") @@ -44,6 +47,35 @@ def _query_component(request: Request, sort_query: bool) -> str: return urlencode(sorted(request.query_params.multi_items(), key=itemgetter(0))) +def _host_component(request: Request) -> str: + """The ``Host`` header as it appears in the key. + + Hostnames are case-insensitive (RFC 9110 section 4.2.3), and an empty port + or the scheme's default one (``:80`` for http, ``:443`` for https) names + the same origin as no port (RFC 3986 section 6.2.3). So the host is + lower-cased and such a port dropped, and every spelling of one origin + shares an entry. An IPv6 literal keeps its brackets. The scheme is the + one the app sees (the ASGI scope's ``scheme``, which ``request.url`` + also uses); behind a TLS-terminating proxy that is ``http`` unless the + proxy's headers are applied. A missing ``Host`` header gives + ``unknown``. + """ + host = request.headers.get("host") + if host is None: + return "unknown" + host = host.lower() + name, colon, port = host.rpartition(":") + # No colon, no name before it, or the last colon is inside an IPv6 + # literal (``[::1]``) or an unbracketed one (not a valid Host, kept as + # sent): no port to drop. + bracketed = name.startswith("[") and name.endswith("]") + if not colon or not name or (":" in name and not bracketed): + return host + if port in ("", _DEFAULT_PORTS.get(request.scope.get("scheme", "http"))): + return name + return host + + def _component_text(component: str | int) -> str: """``component`` as key text: a ``str`` as is, an ``int`` in decimal. @@ -117,7 +149,7 @@ def from_request( """ return cls( method=request.method, - host=request.headers.get("host", "unknown"), + host=_host_component(request), path=request.url.path, query=_query_component(request, sort_query), extra=tuple(_component_text(component) for component in components), diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 1e38d18..6e16505 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -68,7 +68,7 @@ cache_key = "|".join( [ CacheKey.FORMAT_TAG, # "http:v2" escape_key_component(request.method), - escape_key_component(request.headers.get("host", "unknown")), + escape_key_component(host), # Host 標頭,已正規化(見下文) escape_key_component(request.url.path), query, ] @@ -92,7 +92,7 @@ cache_key = "|".join( 這個快取鍵格式讓每個維度各自獨立快取: - **方法隔離**:GET 與 POST 不共用快取(而且目前只有 GET 會進入快取流程) -- **Host 隔離**:`example.com` 與 `api.example.com` 分開快取 +- **Host 隔離**:`example.com` 與 `api.example.com` 分開快取;`Example.com` 與(在 http 上的)`example.com:80` 都是 `example.com` - **路徑隔離**:每個端點有各自的項目 - **查詢參數隔離**:同一端點上不同的查詢參數分開快取 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 5d5d9e8..5c36b84 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -188,7 +188,9 @@ async def search(q: str, limit: int = 10): host 與路徑來自用戶端,因此其中的 `|` 與 `%` 會以百分比編碼寫入(`%7C` 與 `%25`)。含有 `|` 的 `Host` 標頭或路徑因此無法讓各段錯位,使某個請求的快取鍵與另一個請求相同。查詢字串本來就經過 URL 編碼。`clear_path()` 接受應用程式看到的路徑(`request.url.path`),並以同樣方式編碼;`clear_pattern()` 比對的是儲存的快取鍵,所以在模式中要把 `|` 寫成 `%7C`。0.3.8 之前兩者都照原樣儲存,因此升級後,host 或路徑含有 `|` 或 `%` 的項目會重新快取一次。 -host 仍是用戶端送來的任何值。除非應用程式前方的反向代理或負載平衡器已會拒絕未知的 host,否則請加上 Starlette 的 `TrustedHostMiddleware`,讓偽造的 `Host` 得到 `400`,而不是在快取中塞滿沒有其他人會請求的項目: +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`。 + +除此之外,host 仍是用戶端送來的任何值。除非應用程式前方的反向代理或負載平衡器已會拒絕未知的 host,否則請加上 Starlette 的 `TrustedHostMiddleware`,讓偽造的 `Host` 得到 `400`,而不是在快取中塞滿沒有其他人會請求的項目: ```python from starlette.middleware.trustedhost import TrustedHostMiddleware diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 2ab1a01..f297094 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -251,7 +251,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) - 分隔符號改為單一的 `|`(`CACHE_KEY_SEPARATOR`)。 - 鍵以格式標籤 `http:v2|`(`CacheKey.FORMAT_TAG`)開頭,讓下一次格式變更可以用模式移除舊鍵:`clear_pattern("http:v2|*")` 會移除這個格式的所有鍵。 -- 主機名稱會正規化:轉為小寫,並去除該 scheme 的預設連接埠(`:80`、`:443`)。 +- host 會正規化:轉為小寫,並去除空的連接埠或該 scheme 的預設連接埠(http 為 `:80`,https 為 `:443`)。 - 過長的查詢字串(約超過 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)` 讀取鍵的各段。 @@ -263,7 +263,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) 需要修改的地方: -- 直接寫出分隔符號的 `clear_pattern()` 模式(`"GET|||*|||/users/*"`)需要改寫。`clear_path()` 與 `invalidate()` 會自行組出鍵,不需要修改。 +- 直接寫出分隔符號的 `clear_pattern()` 模式(`"GET|||*|||/users/*"`)需要改寫,以大寫或帶預設連接埠寫出 host 的模式也一樣。`clear_path()` 與 `invalidate()` 會自行組出鍵,不需要修改。 - 呼叫 `build_cache_key()` 的自訂 `key_builder` 會自動跟上。自行組出鍵的(以 `CACHE_KEY_SEPARATOR` 串接或直接寫死 `|||`)仍可以快取,也仍能搭配 `invalidate()` 與 `clear_pattern()`,但其鍵沒有 `http:v2` 標籤,因此 `clear_path()` 不再找得到它們,監控路由也不再列出它們。改用 `build_cache_key(request, *components)` 即可兩者都保留。 - 回應取決於用戶端送出的查詢字串順序的處理函式(例如從 `request.url` 複製的自身連結或分頁連結、對原始查詢字串計算的簽章),請設定 `@cache(sort_query=False)`。否則第一位呼叫者的順序會被快取,並提供給送出其他順序的呼叫者。`sort_query=False` 在 0.3.9 就能使用。 - 0.4.0 不會讀取 0.3.x 寫入的項目。這些項目會在 TTL 到期後過期;在 Redis 與記憶體後端上,可以在升級後立即以 `await backend.clear_pattern("*|||*")` 移除。這個模式會比對任何含有 `|||` 的鍵,因此請先確認你自己的鍵(例如 `CacheManager` 的鍵)都不含它。Memcached 無法列舉鍵,只能等它們過期。 diff --git a/tests/test_cache_key_type.py b/tests/test_cache_key_type.py index 2798358..6f5382d 100644 --- a/tests/test_cache_key_type.py +++ b/tests/test_cache_key_type.py @@ -64,7 +64,7 @@ def test_build_cache_key_format_is_pinned() -> None: request = _request("/p|q", b"b=2&a=1", "Host|||x") assert build_cache_key(request, "a|b", 100, sort_query=True) == ( - "http:v2|GET|Host%7C%7C%7Cx|/p%7Cq|a=1&b=2|a%7Cb|100" + "http:v2|GET|host%7C%7C%7Cx|/p%7Cq|a=1&b=2|a%7Cb|100" ) diff --git a/tests/test_host_normalization.py b/tests/test_host_normalization.py new file mode 100644 index 0000000..8b2111c --- /dev/null +++ b/tests/test_host_normalization.py @@ -0,0 +1,91 @@ +"""The host in an HTTP cache key is normalised (#265).""" + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient + +from fastapi_cachex import CacheKey +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 + + +def _request(host: str | None, scheme: str = "http") -> Request: + headers = [] if host is None else [(b"host", host.encode("latin-1"))] + return Request( + { + "type": "http", + "scheme": scheme, + "method": "GET", + "path": "/p", + "raw_path": b"/p", + "query_string": b"", + "headers": headers, + } + ) + + +@pytest.mark.parametrize( + ("host", "scheme", "expected"), + [ + pytest.param("example.com", "http", "example.com", id="plain"), + pytest.param("Example.COM", "http", "example.com", id="case"), + pytest.param("example.com:80", "http", "example.com", id="http-default"), + pytest.param("example.com:443", "https", "example.com", id="https-default"), + pytest.param("example.com:443", "http", "example.com:443", id="http-443"), + pytest.param("example.com:80", "https", "example.com:80", id="https-80"), + pytest.param("example.com:8080", "http", "example.com:8080", id="other-port"), + pytest.param("example.com:", "http", "example.com", id="empty-port"), + pytest.param("Example.com:8080", "https", "example.com:8080", id="case-port"), + pytest.param("[::1]", "http", "[::1]", id="ipv6"), + pytest.param("[::1]:80", "http", "[::1]", id="ipv6-default"), + pytest.param("[::1]:8000", "http", "[::1]:8000", id="ipv6-port"), + pytest.param("[FE80::1]:443", "https", "[fe80::1]", id="ipv6-case"), + pytest.param("::1:80", "http", "::1:80", id="ipv6-unbracketed"), + pytest.param(":80", "http", ":80", id="port-only"), + pytest.param("example.com.", "http", "example.com.", id="trailing-dot"), + pytest.param("example.com:443", "wss", "example.com", id="wss-default"), + pytest.param("example.com:80", "ws", "example.com", id="ws-default"), + pytest.param("[::1]:", "http", "[::1]", id="ipv6-empty-port"), + pytest.param("host:80:80", "http", "host:80:80", id="two-ports"), + pytest.param("HOST:080", "http", "host:080", id="leading-zero-port"), + pytest.param("", "http", "", id="empty"), + pytest.param(None, "http", "unknown", id="missing"), + ], +) +def test_host_is_normalised(host: str | None, scheme: str, expected: str) -> None: + assert CacheKey.from_request(_request(host, scheme)).host == expected + + +def test_spellings_of_one_origin_share_an_entry() -> None: + app = FastAPI() + backend = MemoryBackend() + BackendProxy.set(backend) + calls = 0 + + @app.get("/p") + @cache(ttl=60) + async def endpoint() -> dict[str, int]: + nonlocal calls + calls += 1 + return {"calls": calls} + + client = TestClient(app) + for host in ("example.com", "EXAMPLE.com", "example.com:80"): + assert client.get("/p", headers={"host": host}).json() == {"calls": 1} + + assert list(backend.cache) == ["http:v2|GET|example.com|/p|"] + + +async def test_invalidate_finds_the_entry_under_another_spelling() -> None: + backend = MemoryBackend() + BackendProxy.set(backend) + await backend.set( + CacheKey("GET", "example.com", "/p").to_str(), CacheEntry("e", b"v") + ) + + assert await invalidate(_request("Example.com:80")) is True + assert backend.cache == {}