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):

View File

@@ -3,6 +3,7 @@ import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import yaml
@@ -51,7 +52,7 @@ class TestDirection(unittest.TestCase):
class TestTranslateTree(unittest.TestCase):
def setUp(self):
self.addCleanup(i18n._catalogs.clear)
i18n._catalogs["xx"] = {"A card": "Eine Karte", "Pictures": "Bilder"}
i18n._catalogs["de"] = {"A card": "Eine Karte", "Pictures": "Bilder"}
def test_only_translatable_keys_are_replaced(self):
tree = {
@@ -65,7 +66,7 @@ class TestTranslateTree(unittest.TestCase):
]
}
translated = i18n.translate_tree(tree, "xx")
translated = i18n.translate_tree(tree, "de")
card = translated["cards"][0]
self.assertEqual(card["title"], "Bilder")
@@ -74,26 +75,26 @@ class TestTranslateTree(unittest.TestCase):
self.assertEqual(card["icon"]["class"], "Pictures")
def test_strings_inside_a_list_are_translated(self):
translated = i18n.translate_tree({"text": ["A card", "Pictures"]}, "xx")
translated = i18n.translate_tree({"text": ["A card", "Pictures"]}, "de")
self.assertEqual(translated["text"], ["Eine Karte", "Bilder"])
def test_unknown_strings_keep_their_source_value(self):
translated = i18n.translate_tree({"description": "Untranslated"}, "xx")
translated = i18n.translate_tree({"description": "Untranslated"}, "de")
self.assertEqual(translated["description"], "Untranslated")
def test_source_tree_is_left_untouched(self):
tree = {"name": "Pictures"}
i18n.translate_tree(tree, "xx")
i18n.translate_tree(tree, "de")
self.assertEqual(tree["name"], "Pictures")
def test_non_string_leaves_survive(self):
tree = {"name": 1, "text": None, "info": True}
self.assertEqual(i18n.translate_tree(tree, "xx"), tree)
self.assertEqual(i18n.translate_tree(tree, "de"), tree)
class TestReadCatalog(unittest.TestCase):
@@ -168,6 +169,12 @@ class TestCatalogMerge(unittest.TestCase):
i18n.catalog("de"), {"Close": "Zumachen", "Imprint": "Impressum"}
)
def test_an_unsupported_code_never_becomes_a_path(self):
with mock.patch.object(i18n, "read_catalog") as read:
self.assertEqual(i18n.catalog("../content/de"), {})
read.assert_not_called()
class TestShippedCatalogs(unittest.TestCase):
def test_every_iso_639_1_language_is_offered(self):