fix(i18n): build catalogue paths from the language table, not the request

catalog() joined the requested language code straight into the UI and
content catalogue paths, and read_catalog logged those paths. Both the
negotiated Accept-Language code and the /<lang>/ route only ever pass
supported codes, but that guarantee lived in the callers, so CodeQL
reported path injection and log injection on the request value.

catalog() now resolves the code through a table of the supported
languages and builds the file names from the table's value, so an
unsupported code returns an empty catalogue and never becomes a path.
A unit test holds "../content/de" to that without reading any file,
and the translate_tree fixture moves from the made-up code "xx" to "de"
because unsupported codes now translate to English by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 17:32:19 +02:00
parent 857c470c9e
commit 330881260c
2 changed files with 29 additions and 12 deletions

View File

@@ -43,6 +43,8 @@ UI_STRINGS = (
_catalogs: dict[str, dict[str, str]] = {}
_SUPPORTED = {code: code for code in LANGUAGES}
def direction(code):
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
@@ -82,13 +84,21 @@ def clear_catalogs():
def catalog(code):
"""Return the merged UI and content catalogue for ``code``."""
if code not in _catalogs:
_catalogs[code] = {
**read_catalog(UI_DIR / f"{code}.yaml"),
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
"""Return the merged UI and content catalogue for ``code``.
The file name comes from the supported-language table, never from the
request value itself, so an unsupported code gets an empty catalogue
instead of a path.
"""
known = _SUPPORTED.get(code)
if known is None:
return {}
if known not in _catalogs:
_catalogs[known] = {
**read_catalog(UI_DIR / f"{known}.yaml"),
**read_catalog(CONTENT_DIR / f"{known}.yaml"),
}
return _catalogs[code]
return _catalogs[known]
def negotiate(accepted, default=SOURCE_LANGUAGE):