SEC2-041: Markup inside translated text is trusted everywhere and no check keeps a translation's tags to the source's #983

Closed
opened 2026-10-17 10:39:14 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Low · Category: Injection · Reference: CWE-79 (Improper Neutralization of Input During Web Page Generation); CWE-116 (Improper Encoding or Escaping of Output); OWASP A03:2021 (Injection)

Location:

  • src/postulo/templates/accounts/profile.html:16-21 (<a href="{{ settings_url }}"> inside {% blocktranslate %}), and other blocktranslate blocks with <a>, <em>, <code> inside (applications/calendar.html:214-218, core/dashboard.html:15,31, applications/partials/board.html:23-26, applications/interview_form.html:49, api/token_list.html:134-135, accounts/profile.html:195-197, 290-294)
  • src/postulo/templates/jobs/company_map.html:36 → src/postulo/static/js/app.js:7578-7580 (data-map-here → Leaflet bindTooltip, which writes a string with innerHTML)
  • src/postulo/core/messages_tool.py:899 (message_problems: placeholders and plural forms only)
  • src/postulo/locale/gl/LC_MESSAGES/django.po:696-698, 1136-1138, 1156-1158; src/postulo/locale/sl/LC_MESSAGES/django.po:22974-22976 (foreign markup already in the tree)

Issue

Django's {% blocktranslate %} and {% translate %} emit the translated text as template source: variables inside are escaped, the msgstr itself is not. That is Django's design (catalogues are code), but Postulo has decided otherwise since #806 and #706 (translations come only from Weblate, merged as pull requests on green CI). Three consequences:

  1. Dozens of blocks carry markup in the source string, so a translator must write markup, and may write any markup, e.g.
    {% blocktranslate trimmed %}
      … is under <a href="{{ settings_url }}" class="underline">Settings</a>.
    {% endblocktranslate %}
    
  2. Translated strings also reach a sink that treats them as HTML outside Django: data-map-here="{% translate 'You are about here' %}" becomes L.circleMarker(...).bindTooltip(canvas.getAttribute("data-map-here")), and Leaflet's Tooltip._updateContent assigns a string to innerHTML. The attribution text is escaped before Leaflet writes it (escapeHtml, app.js:7754); the tooltip text is not.
  3. message_problems (run by scripts/messages.py check in CI and by tests/test_translations.py:207) checks placeholders and plural forms. Nothing checks that the tags, attributes and URLs in a msgstr are a subset of the msgid's, and no Weblate safe-html flag is set on the components.

This is not hypothetical. A scan of the catalogues under src/postulo/locale/*/LC_MESSAGES/django.po for msgstr carrying tags their msgid lacks found four entries already in the tree, all from the machine-translation path: three Galician entries hold a paragraph of unrelated wiki text with [[…]] links and a <ref> element in place of three sharing/ownership messages, and a Slovenian entry replaces "Written by %(name)s" with another project's author name and e-mail address. All four are drafts (#, fuzzy) and all four lost their placeholder, so the placeholder check keeps them out of the .mo and would fail them if reviewed. For a source string with no placeholder, nothing would.

Impact

A translator (or a compromised Weblate account; the pull request is merged on CI rather than by reading every string in 40 languages) can inject markup into every page of an instance in that language. The CSP prevents inline script, javascript: navigation and third-party loads, so the realistic outcome is content injection (a fake sign-in link, a misleading notice, hidden text) rather than code execution. Low, by precondition.

Fix

Put a markup rule into messages_tool.message_problems, beside the placeholder rule, so that exactly like a lost placeholder a draft with foreign markup is left out of the .mo by compile_catalogue, a reviewed one fails check and CI, and tests/test_translations.py:207 covers it. The rule: for an entry whose msgid (or plural) contains <, the set of (tag, attribute-name) pairs and of href/src values in each msgstr form must be a subset of the source's, parsed with html.parser; a msgstr may not contain < when the source has none. Set Weblate's safe-html check flag on every component through scripts/weblate.py configure (the API's check_flags), so the translator is told before the pull request exists. In app.js:7579, pass escapeHtml(…) to bindTooltip, or build a text node and hand Leaflet the element.

Done when: a fixture catalogue with a <script> msgstr for a placeholder-free msgid is left out of the .mo as a draft and fails check as a reviewed entry, and tests/e2e/test_map.py asserts the "here" tooltip shows a data-map-here containing <b> as text.

#910 (open) - the same change to message_problems serves its item 5. SEC2-040 - the company form's |safe on a translated string, the same actor. #806 (closed) - Python format_html sites only. #706 (closed) - the Weblate-only workflow, which mentions no content check. #497 (closed) - translation checks.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Injection · **Reference:** CWE-79 (Improper Neutralization of Input During Web Page Generation); CWE-116 (Improper Encoding or Escaping of Output); OWASP A03:2021 (Injection) **Location:** - `src/postulo/templates/accounts/profile.html:16-21` (`<a href="{{ settings_url }}">` inside `{% blocktranslate %}`), and other `blocktranslate` blocks with `<a>`, `<em>`, `<code>` inside (`applications/calendar.html:214-218`, `core/dashboard.html:15,31`, `applications/partials/board.html:23-26`, `applications/interview_form.html:49`, `api/token_list.html:134-135`, `accounts/profile.html:195-197, 290-294`) - `src/postulo/templates/jobs/company_map.html:36` → `src/postulo/static/js/app.js:7578-7580` (`data-map-here` → Leaflet `bindTooltip`, which writes a string with `innerHTML`) - `src/postulo/core/messages_tool.py:899` (`message_problems`: placeholders and plural forms only) - `src/postulo/locale/gl/LC_MESSAGES/django.po:696-698, 1136-1138, 1156-1158`; `src/postulo/locale/sl/LC_MESSAGES/django.po:22974-22976` (foreign markup already in the tree) ## Issue Django's `{% blocktranslate %}` and `{% translate %}` emit the translated text as template source: variables inside are escaped, the msgstr itself is not. That is Django's design (catalogues are code), but Postulo has decided otherwise since #806 and #706 (translations come only from Weblate, merged as pull requests on green CI). Three consequences: 1. Dozens of blocks carry markup in the source string, so a translator must write markup, and may write any markup, e.g. ```django {% blocktranslate trimmed %} … is under <a href="{{ settings_url }}" class="underline">Settings</a>. {% endblocktranslate %} ``` 2. Translated strings also reach a sink that treats them as HTML outside Django: `data-map-here="{% translate 'You are about here' %}"` becomes `L.circleMarker(...).bindTooltip(canvas.getAttribute("data-map-here"))`, and Leaflet's `Tooltip._updateContent` assigns a string to `innerHTML`. The attribution text is escaped before Leaflet writes it (`escapeHtml`, `app.js:7754`); the tooltip text is not. 3. `message_problems` (run by `scripts/messages.py check` in CI and by `tests/test_translations.py:207`) checks placeholders and plural forms. Nothing checks that the tags, attributes and URLs in a msgstr are a subset of the msgid's, and no Weblate `safe-html` flag is set on the components. This is not hypothetical. A scan of the catalogues under `src/postulo/locale/*/LC_MESSAGES/django.po` for msgstr carrying tags their msgid lacks found four entries already in the tree, all from the machine-translation path: three Galician entries hold a paragraph of unrelated wiki text with `[[…]]` links and a `<ref>` element in place of three sharing/ownership messages, and a Slovenian entry replaces "Written by <bdi>%(name)s</bdi>" with another project's author name and e-mail address. All four are drafts (`#, fuzzy`) and all four lost their placeholder, so the placeholder check keeps them out of the `.mo` and would fail them if reviewed. For a source string with no placeholder, nothing would. ## Impact A translator (or a compromised Weblate account; the pull request is merged on CI rather than by reading every string in 40 languages) can inject markup into every page of an instance in that language. The CSP prevents inline script, `javascript:` navigation and third-party loads, so the realistic outcome is content injection (a fake sign-in link, a misleading notice, hidden text) rather than code execution. Low, by precondition. ## Fix Put a markup rule into `messages_tool.message_problems`, beside the placeholder rule, so that exactly like a lost placeholder a draft with foreign markup is left out of the `.mo` by `compile_catalogue`, a reviewed one fails `check` and CI, and `tests/test_translations.py:207` covers it. The rule: for an entry whose msgid (or plural) contains `<`, the set of `(tag, attribute-name)` pairs and of `href`/`src` values in each msgstr form must be a subset of the source's, parsed with `html.parser`; a msgstr may not contain `<` when the source has none. Set Weblate's `safe-html` check flag on every component through `scripts/weblate.py configure` (the API's `check_flags`), so the translator is told before the pull request exists. In `app.js:7579`, pass `escapeHtml(…)` to `bindTooltip`, or build a text node and hand Leaflet the element. Done when: a fixture catalogue with a `<script>` msgstr for a placeholder-free msgid is left out of the `.mo` as a draft and fails `check` as a reviewed entry, and `tests/e2e/test_map.py` asserts the "here" tooltip shows a `data-map-here` containing `<b>` as text. ## Related #910 (open) - the same change to `message_problems` serves its item 5. SEC2-040 - the company form's `|safe` on a translated string, the same actor. #806 (closed) - Python `format_html` sites only. #706 (closed) - the Weblate-only workflow, which mentions no content check. #497 (closed) - translation checks.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:39:15 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#983
No description provided.