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
6 changes: 6 additions & 0 deletions changelog.d/265.changed.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand Down Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions 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))

- 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.
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``|``.
Expand Down
34 changes: 33 additions & 1 deletion fastapi_cachex/cache_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("*?[]\\")

Expand All @@ -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.

Expand Down Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions i18n/zh-TW/docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand All @@ -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`
- **路徑隔離**:每個端點有各自的項目
- **查詢參數隔離**:同一端點上不同的查詢參數分開快取

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 @@ -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
Expand Down
4 changes: 2 additions & 2 deletions i18n/zh-TW/docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` 讀取鍵的各段。
Expand All @@ -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 無法列舉鍵,只能等它們過期。
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cache_key_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)


Expand Down
91 changes: 91 additions & 0 deletions tests/test_host_normalization.py
Original file line number Diff line number Diff line change
@@ -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 == {}
Loading