PST-077: format_html is called with translated catalogue strings as the format string #806

Closed
opened 2026-10-10 10:56:47 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 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.


  • Severity: Informational
  • Category: html-building / trust-in-translations
  • Location: src/postulo/jobs/capture_views.py (452-457,497,606-609)
  • Found by: XSS and CSP

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 draft flag 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 have scripts/messages.py check reject 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; use mark_safe(escape(gettext(...)).format(...)) or format_html("{}", ...) with the sentence split around the link as accounts/forms.py does), and keep the convention in CONTRIBUTING.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 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. --- - **Severity:** Informational - **Category:** html-building / trust-in-translations - **Location:** `src/postulo/jobs/capture_views.py` (452-457,497,606-609) - **Found by:** XSS and CSP **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 '&lt;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 `draft` flag 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 have `scripts/messages.py check` reject a msgstr containing '&lt;' 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; use `mark_safe(escape(gettext(...)).format(...))` or `format_html("{}", ...)` with the sentence split around the link as accounts/forms.py does), and keep the convention in CONTRIBUTING.
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:10 +00:00
Author
Owner

Fixed in 7489ebaab on main.

Fixed in 7489ebaab on main.
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#806
No description provided.