SEC2-040: Company edit page marks a translated catalogue string |safe, against the project's own #806 rule #982

Closed
opened 2026-10-17 10:39:12 +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); OWASP A03:2021 (Injection)

Location:

  • src/postulo/templates/jobs/company_form.html:147-149
  • src/postulo/core/web_links.py:92-96, 113-116, 134-137, 157-160 (the strings)
  • src/postulo/templates/accounts/profile.html:307-308, src/postulo/templates/jobs/contact_form.html:51-52 (the same strings rendered escaped)

Issue

Block.kept_back / kept_back_contact are gettext_lazy sentences that carry markup:

kept_back_contact=_(
    "Some social profiles are kept for this contact and not shown, because "
    "<em>Multiple social profiles</em> is switched off."
),

The company form prints the translation as markup:

{% for block, count in links_kept_back %}
  <p class="mb-4 text-sm text-ink-500 dark:text-ink-400">{{ block.kept_back_contact|safe }}</p>
{% endfor %}

core/htmlstrings.py (landed for #806) states the rule this breaks: "A catalogue is edited on a translation server by people the project does not vet one string at a time, so it is data to be escaped, never code to be trusted." Whatever a translator writes into this msgstr in any of the ~40 catalogues reaches the company edit page unescaped. The two sibling templates render the same strings without |safe (profile.html:308, contact_form.html:52), which is safe but shows the literal text <em>…</em> to the reader. That functional bug is why the unsafe rendering is the one that looks right.

Impact

A person with write access to a translation (a Weblate contributor, or anyone whose catalogue pull request is merged on green CI) can place arbitrary markup on the company edit page of every instance that ships that language: a forged form or link, injected content. The CSP (script-src 'self', no unsafe-inline) stops inline script, so this is markup injection rather than script execution, and it needs a translator-level precondition, hence Low.

Fix

Split the setting's name out of each string in web_links.py and compose with htmlstrings.sentence, which escapes the translation and fills only named slots. Reuse the feature's own label (plugins/social_profiles/__init__.py:31) so translators translate it once:

kept_back_contact=_(
    "Some social profiles are kept for this contact and not shown, because {setting} is switched off."
),

Give Block two properties, kept_back_html and kept_back_contact_html, returning htmlstrings.sentence(self.kept_back, setting=format_html("<em>{}</em>", self.setting)); the three templates print the property without |safe. Add a lint next to the others in tests/test_template_lint.py that refuses |safe in any template outside an allow-list of exactly offline.html and partials/messages.html, with a comment saying why each is allowed.

Done when: the lint is red on the current code and green after the change, and a rendering test with a catalogue fixture whose msgstr for this entry holds <script> shows &lt;script&gt; and no <script> on the company form, the contact form and the profile, while the English pages show italic text rather than a literal <em>.

#806 (closed) - "format_html is called with translated catalogue strings as the format string": same weakness class, but its call sites were Python format_html uses; this |safe on a translated string in a template was not among them, so the fix is incomplete. #906 (closed) - introduced the strings and the |safe.

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); OWASP A03:2021 (Injection) **Location:** - `src/postulo/templates/jobs/company_form.html:147-149` - `src/postulo/core/web_links.py:92-96, 113-116, 134-137, 157-160` (the strings) - `src/postulo/templates/accounts/profile.html:307-308`, `src/postulo/templates/jobs/contact_form.html:51-52` (the same strings rendered escaped) ## Issue `Block.kept_back` / `kept_back_contact` are `gettext_lazy` sentences that carry markup: ```python kept_back_contact=_( "Some social profiles are kept for this contact and not shown, because " "<em>Multiple social profiles</em> is switched off." ), ``` The company form prints the *translation* as markup: ```django {% for block, count in links_kept_back %} <p class="mb-4 text-sm text-ink-500 dark:text-ink-400">{{ block.kept_back_contact|safe }}</p> {% endfor %} ``` `core/htmlstrings.py` (landed for #806) states the rule this breaks: "A catalogue is edited on a translation server by people the project does not vet one string at a time, so it is data to be escaped, never code to be trusted." Whatever a translator writes into this msgstr in any of the ~40 catalogues reaches the company edit page unescaped. The two sibling templates render the same strings *without* `|safe` (`profile.html:308`, `contact_form.html:52`), which is safe but shows the literal text `<em>…</em>` to the reader. That functional bug is why the unsafe rendering is the one that looks right. ## Impact A person with write access to a translation (a Weblate contributor, or anyone whose catalogue pull request is merged on green CI) can place arbitrary markup on the company edit page of every instance that ships that language: a forged form or link, injected content. The CSP (`script-src 'self'`, no `unsafe-inline`) stops inline script, so this is markup injection rather than script execution, and it needs a translator-level precondition, hence Low. ## Fix Split the setting's name out of each string in `web_links.py` and compose with `htmlstrings.sentence`, which escapes the translation and fills only named slots. Reuse the feature's own label (`plugins/social_profiles/__init__.py:31`) so translators translate it once: ```python kept_back_contact=_( "Some social profiles are kept for this contact and not shown, because {setting} is switched off." ), ``` Give `Block` two properties, `kept_back_html` and `kept_back_contact_html`, returning `htmlstrings.sentence(self.kept_back, setting=format_html("<em>{}</em>", self.setting))`; the three templates print the property without `|safe`. Add a lint next to the others in `tests/test_template_lint.py` that refuses `|safe` in any template outside an allow-list of exactly `offline.html` and `partials/messages.html`, with a comment saying why each is allowed. Done when: the lint is red on the current code and green after the change, and a rendering test with a catalogue fixture whose msgstr for this entry holds `<script>` shows `&lt;script&gt;` and no `<script>` on the company form, the contact form and the profile, while the English pages show italic text rather than a literal `<em>`. ## Related #806 (closed) - "format_html is called with translated catalogue strings as the format string": same weakness class, but its call sites were Python `format_html` uses; this `|safe` on a translated string in a template was not among them, so the fix is incomplete. #906 (closed) - introduced the strings and the `|safe`.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:39:13 +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#982
No description provided.