From 68ddbf2e370989a9d68c6e772b33fa9da3f8b9c9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 10:05:21 +0100 Subject: [PATCH 1/2] fix(hosting): never negotiate an Accept-Language entry with q=0 (#792) Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 4 +++ docs/framework/i18n.md | 2 +- docs/framework/middleware.md | 2 +- .../simple_module_hosting/i18n_middleware.py | 8 +++++- .../hosting/tests/test_locale_middleware.py | 27 +++++++++++++++++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c400db8e8..37e07bb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ 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 +- `LocaleMiddleware` never picks a language the browser sends with `q=0` + ("not acceptable"), including through a regional tag such as `es-MX;q=0`; + `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/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..87213ce79 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 @@ -80,7 +82,11 @@ def _negotiate(self, accept_language: str) -> str | None: q = float(q_part.split("=", 1)[1]) if q_part.startswith("q=") else 1.0 except ValueError: q = 1.0 - candidates.append((q, tag)) + # q=0 means "not acceptable" (RFC 9110 §12.4.2); nan is no weight at + # all. Weights above 1 count as 1 so the sort stays well defined. + 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..17a5f7b95 100644 --- a/framework/hosting/tests/test_locale_middleware.py +++ b/framework/hosting/tests/test_locale_middleware.py @@ -97,3 +97,30 @@ 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 From a2d9e02cf7fd4f3f7a3288af6005bd1f4ec54e04 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 10:08:24 +0100 Subject: [PATCH 2/2] fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 5 ++-- docs/framework-conventions.md | 2 +- .../simple_module_hosting/i18n_middleware.py | 24 ++++++++++++------- .../hosting/tests/test_locale_middleware.py | 8 +++++++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e07bb0f..471941d40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,8 +53,9 @@ 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 -- `LocaleMiddleware` never picks a language the browser sends with `q=0` - ("not acceptable"), including through a regional tag such as `es-MX;q=0`; +- **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 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/framework/hosting/simple_module_hosting/i18n_middleware.py b/framework/hosting/simple_module_hosting/i18n_middleware.py index 87213ce79..5ec4d5d9d 100644 --- a/framework/hosting/simple_module_hosting/i18n_middleware.py +++ b/framework/hosting/simple_module_hosting/i18n_middleware.py @@ -75,15 +75,21 @@ 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 - # q=0 means "not acceptable" (RFC 9110 §12.4.2); nan is no weight at - # all. Weights above 1 count as 1 so the sort stays well defined. + 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)) diff --git a/framework/hosting/tests/test_locale_middleware.py b/framework/hosting/tests/test_locale_middleware.py index 17a5f7b95..b962e1820 100644 --- a/framework/hosting/tests/test_locale_middleware.py +++ b/framework/hosting/tests/test_locale_middleware.py @@ -124,3 +124,11 @@ def test_non_finite_and_out_of_range_q_values() -> None: 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