SEC2-041: Markup inside translated text is trusted everywhere and no check keeps a translation's tags to the source's #983
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#983
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-10-16 in a read-only security review of
postuloat56fc9d531, 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 otherblocktranslateblocks 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→ LeafletbindTooltip, which writes a string withinnerHTML)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:data-map-here="{% translate 'You are about here' %}"becomesL.circleMarker(...).bindTooltip(canvas.getAttribute("data-map-here")), and Leaflet'sTooltip._updateContentassigns a string toinnerHTML. The attribution text is escaped before Leaflet writes it (escapeHtml,app.js:7754); the tooltip text is not.message_problems(run byscripts/messages.py checkin CI and bytests/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 Weblatesafe-htmlflag is set on the components.This is not hypothetical. A scan of the catalogues under
src/postulo/locale/*/LC_MESSAGES/django.pofor 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.moand 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.mobycompile_catalogue, a reviewed one failscheckand CI, andtests/test_translations.py:207covers it. The rule: for an entry whose msgid (or plural) contains<, the set of(tag, attribute-name)pairs and ofhref/srcvalues in each msgstr form must be a subset of the source's, parsed withhtml.parser; a msgstr may not contain<when the source has none. Set Weblate'ssafe-htmlcheck flag on every component throughscripts/weblate.py configure(the API'scheck_flags), so the translator is told before the pull request exists. Inapp.js:7579, passescapeHtml(…)tobindTooltip, 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.moas a draft and failscheckas a reviewed entry, andtests/e2e/test_map.pyasserts the "here" tooltip shows adata-map-herecontaining<b>as text.Related
#910 (open) - the same change to
message_problemsserves its item 5. SEC2-040 - the company form's|safeon a translated string, the same actor. #806 (closed) - Pythonformat_htmlsites only. #706 (closed) - the Weblate-only workflow, which mentions no content check. #497 (closed) - translation checks.