SEC2-040: Company edit page marks a translated catalogue string |safe, against the project's own #806 rule #982
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#982
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); OWASP A03:2021 (Injection)
Location:
src/postulo/templates/jobs/company_form.html:147-149src/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_contactaregettext_lazysentences that carry markup:The company form prints the translation as markup:
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', nounsafe-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.pyand compose withhtmlstrings.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:Give
Blocktwo properties,kept_back_htmlandkept_back_contact_html, returninghtmlstrings.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 intests/test_template_lint.pythat refuses|safein any template outside an allow-list of exactlyoffline.htmlandpartials/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<script>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_htmluses; this|safeon a translated string in a template was not among them, so the fix is incomplete. #906 (closed) - introduced the strings and the|safe.