PST-077: format_html is called with translated catalogue strings as the format string #806
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#806
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 in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 8 to 10 October 2026, as PST-077. Severity: Informational.The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.
src/postulo/jobs/capture_views.py(452-457,497,606-609)Who can do it, and what they need. Whoever can land a translation in a catalogue that the instance loads: a reviewed contributor to locale/, or the author of a plugin (plugins ship their own locale/, CLAUDE.md). No path for an ordinary user.
How it works.
A .po entry for 'Added to the history of {listing}.' is written as '<img src=x onerror=...>{listing}'; format_html treats the format string as safe markup and only escapes the arguments; the message is stored with extra_tags="safe" and partials/messages.html prints it with |safe. The CSP blocks inline event handlers and scripts, so the practical effect is markup injection/defacement rather than script.
Impact. Markup injection into the messages area and form help texts by a translator or plugin catalogue; script is blocked by the policy. Recorded because the repository's own pattern elsewhere is correct and the inconsistency is easy to fix.
Verification (verdict: confirmed; severity after verification: Informational). Attacker is limited to someone who can merge a .po change into the core locale/ (a translation contributor, caught only by a speaker's review, since the
draftflag is not a markup check); a plugin author adds nothing beyond the trust already placed in plugins. Script execution is blocked by the CSP, but a meta refresh or link in a flash message is not. Hardening: escape the translated sentence and substitute pre-escaped pieces (the accounts/forms.py:221 pattern), or havescripts/messages.py checkreject a msgstr containing '<' when the msgid has none. Confidence in the line numbers is good: capture_views.py:452-457, 497 and 606-609, documents/forms.py:199 and 264-305, and applications/models.py:853-873 all match.Suggested remediation. Escape the translated sentence before it becomes the format string (
format_html(escape(_("...")), ...)is not enough because format_html re-marks; usemark_safe(escape(gettext(...)).format(...))orformat_html("{}", ...)with the sentence split around the link as accounts/forms.py does), and keep the convention in CONTRIBUTING.Fixed in
7489ebaabon main.|safe, against the project's own #806 rule #982