diff --git a/CHANGELOG.md b/CHANGELOG.md index c400db8e8..471941d40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,11 @@ All notable changes to this project are documented in this file. The format is b `account` or whose `azp` is another client (only `oidc` pins both). ### Fixed +- **Behaviour change:** `LocaleMiddleware` never picks a language the browser + sends with `q=0` ("not acceptable"), including through a regional tag such + as `es-MX;q=0` and when `q` follows another parameter or is spelled `Q`; + `q=nan` and negative weights are ignored and weights above 1 count as 1 + (#792). - **Behaviour change:** `RedactionFilter` on ordinary logging handlers masks secrets in tracebacks and stack information, including exception text not yet formatted when the filter runs (#789). diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index cddffebef..609b38dae 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -750,7 +750,7 @@ The default locale must be in the supported list (enforced by a pydantic validat Per request, `LocaleMiddleware` picks the active locale in this order: 1. Cookie named by `SM_I18N_COOKIE_NAME` (default `locale`), validated against `SM_I18N_SUPPORTED_LOCALES`. -2. `Accept-Language` header, with q-value parsing and longest-prefix match (`es-MX` → `es`). +2. `Accept-Language` header, with q-value parsing and longest-prefix match (`es-MX` → `es`); a language sent with `q=0` is never chosen. 3. `SM_I18N_DEFAULT_LOCALE`. The active locale lands on `request.state.locale`. The `` component POSTs to `/i18n/set-locale`, which sets a 1-year cookie and redirects back. diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index 19ae33eb9..8edc6821c 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -134,7 +134,7 @@ Behavior matches across the stack: Babel's CLDR rules on the backend, `Intl.Plur `LocaleMiddleware` picks the active locale per request in this order: 1. Cookie named by `SM_I18N_COOKIE_NAME` (default `locale`), validated against `SM_I18N_SUPPORTED_LOCALES`. -2. `Accept-Language` header, with q-value parsing and longest-prefix match (`es-MX` → `es`). +2. `Accept-Language` header, with q-value parsing and longest-prefix match (`es-MX` → `es`); a language sent with `q=0` is never chosen. 3. `SM_I18N_DEFAULT_LOCALE`. Resolved locale lands on `request.state.locale`. diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 0af80ca19..2f71dee62 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -187,7 +187,7 @@ Header lookup is **off unless you name a header**: `tenant_header` defaults to ` Resolves the active locale in this order per request: 1. Cookie named by `SM_I18N_COOKIE_NAME` (validated against supported locales). -2. `Accept-Language` header with q-value parsing and longest-prefix match (`es-MX` → `es`). +2. `Accept-Language` header with q-value parsing and longest-prefix match (`es-MX` → `es`); a language sent with `q=0` is never chosen. 3. `SM_I18N_DEFAULT_LOCALE`. The resolved locale lands on `request.state.locale` and is used by `InertiaLayoutDataMiddleware` to pick the translation bundle for the shared props. diff --git a/framework/hosting/simple_module_hosting/i18n_middleware.py b/framework/hosting/simple_module_hosting/i18n_middleware.py index d2913893d..5ec4d5d9d 100644 --- a/framework/hosting/simple_module_hosting/i18n_middleware.py +++ b/framework/hosting/simple_module_hosting/i18n_middleware.py @@ -2,6 +2,8 @@ from __future__ import annotations +import math + from starlette.datastructures import Headers from starlette.requests import Request from starlette.types import ASGIApp, Receive, Scope, Send @@ -73,14 +75,24 @@ def _negotiate(self, accept_language: str) -> str | None: part = part.strip() if not part: continue - tag, _, q_part = part.partition(";") - tag = tag.strip().lower() - q_part = q_part.strip().lower() - try: - q = float(q_part.split("=", 1)[1]) if q_part.startswith("q=") else 1.0 - except ValueError: - q = 1.0 - candidates.append((q, tag)) + tag, *params = (piece.strip() for piece in part.split(";")) + tag = tag.lower() + q = 1.0 + for param in params: + name, _, value = param.partition("=") + if name.strip().lower() != "q": + continue + try: + q = float(value.strip()) + except ValueError: + q = 1.0 + break + # q=0 (or negative) means "not acceptable" (RFC 9110 §12.4.2); nan is + # no weight at all. Weights above 1 are out of range, so they count + # as 1 and cannot outrank an unweighted tag. + if math.isnan(q) or q <= 0: + continue + candidates.append((min(q, 1.0), tag)) # Sort by q descending, stable. candidates.sort(key=lambda pair: -pair[0]) diff --git a/framework/hosting/tests/test_locale_middleware.py b/framework/hosting/tests/test_locale_middleware.py index 034513810..b962e1820 100644 --- a/framework/hosting/tests/test_locale_middleware.py +++ b/framework/hosting/tests/test_locale_middleware.py @@ -97,3 +97,38 @@ def test_lower_q_supported_beats_higher_q_unsupported() -> None: headers={"Accept-Language": "de;q=1.0,es;q=0.1"}, ) assert resp.json() == {"locale": "es"} + + +def test_q_zero_language_is_never_chosen() -> None: + """RFC 9110 §12.4.2: ``q=0`` means "not acceptable" (#792).""" + mw = LocaleMiddleware(lambda *a: None, supported_locales=["en", "es"], default_locale="en") + assert mw._negotiate("es;q=0") is None + assert mw._negotiate("es;q=0.000, en;q=0.1") == "en" + # A refused regional tag does not fall back to its primary language either. + assert mw._negotiate("es-MX;q=0") is None + + +def test_q_zero_header_resolves_to_default() -> None: + client = TestClient(_build_app(["en", "es"], "en")) + assert client.get("/", headers={"accept-language": "es;q=0"}).json() == {"locale": "en"} + + +def test_non_finite_and_out_of_range_q_values() -> None: + mw = LocaleMiddleware(lambda *a: None, supported_locales=["en", "es"], default_locale="en") + # nan is not a weight: the candidate is ignored. + assert mw._negotiate("es;q=nan") is None + assert mw._negotiate("es;q=nan, en;q=0.5") == "en" + # Weights above 1 (inf included) count as 1, so header order breaks the tie. + assert mw._negotiate("es;q=inf, en") == "es" + assert mw._negotiate("en, es;q=inf") == "en" + assert mw._negotiate("en, es;q=5") == "en" + # A negative weight is below 0 and therefore not acceptable. + assert mw._negotiate("es;q=-1") is None + + +def test_q_zero_is_found_among_other_parameters_and_spacing() -> None: + mw = LocaleMiddleware(lambda *a: None, supported_locales=["en", "es"], default_locale="en") + assert mw._negotiate("es;charset=utf-8;q=0") is None + assert mw._negotiate("es; q = 0, en;q=0.1") == "en" + assert mw._negotiate("es;q=0;") is None + assert mw._negotiate("es;Q=0") is None