PST-041: Imports and remote metadata store URLs without a scheme check, and pages render them as href #770
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#770
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-041. Severity: Low.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/core/importer.py(672-693; 1376,1462,685-693);src/postulo/core/kind_files.py(690-692);src/postulo/core/updates.py(86)Who can do it, and what they need. Someone who can alter an archive the victim will restore through Settings → import (a tampered backup, an archive handed over when moving instances, a hosting provider). Precondition: the victim restores that archive and later clicks the link; the archive importer is the one door into the URL columns that does not validate (forms use URLField, the API uses core.addresses.web_address/page_address, CSV import checks for http(s)).
How it works.
Impact. Script execution in the victim's own session on click: reads the CSRF token the body carries (data-browser-notices-token), can submit any form (export the archive, change email/password, create API tokens), and read every page. The CSP does not stop a javascript: navigation from a user click. Scope is the victim's own account, which is why it is low rather than high.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Door:
manage.py import_data(core/management/commands/import_data.py:34) run by the operator, not a Settings page; there is no web route that callsimporter.load. Affected columns on restore: Company.website/careers_url (COMPANY_FIELDS via _carried, importer.py:1349), JobPosting.url (1462-1466), WebLink.url for profile and contact links (674-693), DocumentCopy.external_url (263), and every resume section'surl/credential_url(1164-1199, e.g. Project.url rendered at resume/overview.html:81). Impact: the stored address is rendered as an href, but under the project's CSP (script-src 'self', no 'unsafe-inline') browsers refuse thejavascript:navigation, anddata:top-level navigations are blocked; no script runs in a current browser. The gap is defence in depth (one CSP regression or a non-CSP webview away from script as the victim) plus a breach of the project's stated rule. Remediation: pass each address throughcore.addresses.web_address()/page_address()in_restore_web_links,_restore_copiesand before_carriedoutput reaches the constructors, dropping the value and saying so inreport.skipped; add a test beside tests/security/test_import_isolation.py.Suggested remediation. Run core.addresses.web_address() (or page_address() for a listing's address) over every URL-shaped field the importer writes — web_links.url, posting.url, company.website/careers_url/logo_source_url, DocumentCopy.external_url, and the resume entries' url/credential_url/doi — dropping the value and recording it in the ImportReport when it is not http(s); or build those rows through the same ModelForms the pages use (full_clean). As defence in depth, add a template filter (e.g. |web_href) that the templates apply to every stored address so that a non-http(s) value renders as text, and a test in tests/security/ that restores an archive carrying javascript: in each field.
Also covered by this issue.
src/postulo/core/importer.py(1376,1462,685-693); Low). Same as the kind-file case but behind an operator-run command: stored script/data scheme links in the imported account, neutralised today by the CSP (javascript: blocked) and browser rules (data: top-level navigation blocked). No cross-account effect (tests/security/test_import_isolation.py holds).src/postulo/core/kind_files.py(690-692); Low). A stored link with a script or data scheme in the person's own account. Clicking it is a javascript: navigation, which the CSP (script-src 'self', no 'unsafe-inline') blocks in current browsers, and a data: top-level navigation is blocked by Chrome and Firefox, so today this is defence-in-depth …src/postulo/core/updates.py(86); Informational). Script in an administrator's session (server settings, accounts, plugin installation). Narrow: it needs a hostile or compromised release feed, over HTTPS, and a click. The plugin/store paths are informational since the code is already running on the instance.Fixed in
a7c451787on main.href#32