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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LocaleSwitcher />` component POSTs to `/i18n/set-locale`, which sets a 1-year cookie and redirects back.
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 20 additions & 8 deletions framework/hosting/simple_module_hosting/i18n_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
35 changes: 35 additions & 0 deletions framework/hosting/tests/test_locale_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading