PST-041: Imports and remote metadata store URLs without a scheme check, and pages render them as href #770

Closed
opened 2026-10-10 10:56:14 +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-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.


  • Severity: Low
  • Category: stored-xss / unsafe-href
  • Location: 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)
  • Found by: XSS and CSP, injection and parsers
  • Related: PST-093 (#822), PST-094 (#823)

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.

  1. Attacker edits the archive JSON: a contact's web_links row {"kind":"website","url":"javascript:alert(document.cookie)","is_primary":true}, or a listing's "url", a company's "website", a copy's "external_url" or a career entry's "url".
  2. Victim restores the archive; _restore_web_links / JobPosting.objects.create / Company creation write the value verbatim (no full_clean, no web_address()).
  3. Victim opens the company page (contact links, company website) or the listing page; the template writes <a href="javascript:...">.
  4. Victim clicks; the script runs in their session. Verified with a scratch test: WebLink stored with url 'javascript:alert(document.cookie)' and company_detail rendered href="javascript:alert(document.cookie)"; JobPosting created with url 'javascript:alert(1)' rendered as href on posting_detail.

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.

importer.py:674-693: `url = (row.get("url") or "").strip()[:500]` ... `WebLink.objects.create(owner=owner, holder=holder, kind=kind, service=service, label=label, url=url, is_primary=is_primary)`; importer.py:1462-1466: `posting = JobPosting.objects.create(owner=user, company=company, **_carried(posting_entry, POSTING_FIELDS, report, "A listing"))` where _carried (270-) only filters key names; export.py:390-397 POSTING_FIELDS include "url"; COMPANY_FIELDS (350-355) include "website", "careers_url"; importer.py:263 `external_url=str(entry.get("external_url", ""))[:500]`; company_detail.html:383 `<a href="{{ link.url }}" rel="noopener noreferrer external" target="_blank"`; posting_detail.html:
…

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 calls importer.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's url/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 the javascript: navigation, and data: 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 through core.addresses.web_address()/page_address() in _restore_web_links, _restore_copies and before _carried output reaches the constructors, dropping the value and saying so in report.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.

  • Full-archive import (manage.py import_data) stores unvalidated URL schemes on companies, listings and profile links (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).
  • Contacts kind-file import stores javascript:/data: web links that pages render as href (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 …
  • Links taken from remote metadata (release feed, plugin wheel metadata, store plugins) are rendered as href without a scheme check on administrator pages (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.
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-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. --- - **Severity:** Low - **Category:** stored-xss / unsafe-href - **Location:** `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) - **Found by:** XSS and CSP, injection and parsers - **Related:** PST-093 (#822), PST-094 (#823) **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.** 1. Attacker edits the archive JSON: a contact's web_links row {"kind":"website","url":"javascript:alert(document.cookie)","is_primary":true}, or a listing's "url", a company's "website", a copy's "external_url" or a career entry's "url". 2. Victim restores the archive; _restore_web_links / JobPosting.objects.create / Company creation write the value verbatim (no full_clean, no web_address()). 3. Victim opens the company page (contact links, company website) or the listing page; the template writes &lt;a href="javascript:...">. 4. Victim clicks; the script runs in their session. Verified with a scratch test: WebLink stored with url 'javascript:alert(document.cookie)' and company_detail rendered href="javascript:alert(document.cookie)"; JobPosting created with url 'javascript:alert(1)' rendered as href on posting_detail. **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.** ```text importer.py:674-693: `url = (row.get("url") or "").strip()[:500]` ... `WebLink.objects.create(owner=owner, holder=holder, kind=kind, service=service, label=label, url=url, is_primary=is_primary)`; importer.py:1462-1466: `posting = JobPosting.objects.create(owner=user, company=company, **_carried(posting_entry, POSTING_FIELDS, report, "A listing"))` where _carried (270-) only filters key names; export.py:390-397 POSTING_FIELDS include "url"; COMPANY_FIELDS (350-355) include "website", "careers_url"; importer.py:263 `external_url=str(entry.get("external_url", ""))[:500]`; company_detail.html:383 `<a href="{{ link.url }}" rel="noopener noreferrer external" target="_blank"`; posting_detail.html: … ``` **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 calls `importer.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's `url`/`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 the `javascript:` navigation, and `data:` 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 through `core.addresses.web_address()`/`page_address()` in `_restore_web_links`, `_restore_copies` and before `_carried` output reaches the constructors, dropping the value and saying so in `report.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.** - **Full-archive import (manage.py import_data) stores unvalidated URL schemes on companies, listings and profile links** (`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). - **Contacts kind-file import stores javascript:/data: web links that pages render as href** (`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 … - **Links taken from remote metadata (release feed, plugin wheel metadata, store plugins) are rendered as href without a scheme check on administrator pages** (`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.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:46 +00:00
Author
Owner

Fixed in a7c451787 on main.

Fixed in a7c451787 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#770
No description provided.