SEC2-088: A web link saved from a card or an import is stored with no scheme check in the core's save_only_link #991

Closed
opened 2026-10-17 10:39:39 +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: Input Validation · Reference: CWE-20 (Improper Input Validation); CWE-79 (Improper Neutralization of Input During Web Page Generation); OWASP A03:2021 (Injection)

Location:

  • src/postulo/core/web_links.py:280-317 (save_only_link: typed = (typed or "").strip(), then primary.url = typed; primary.save())
  • src/postulo/core/link_services.py:471-501 (settle: with nothing chosen, guess() or ("", label); it refuses no address)
  • src/postulo/plugins/api.py:116,277,357 (save_web_link on the plugin surface is save_only_link)
  • src/postulo/resume/importing.py:293 and src/postulo/core/management/commands/seed_demo.py:281 (the core's own callers)
  • src/postulo/core/models.py:913 (WebLink.url, a URLField(max_length=500) whose validator runs only on full_clean)
  • src/postulo/templates/jobs/company_detail.html:159,500, src/postulo/templates/documents/application_documents.html:68 (<a href="{{ link.url }}" ...>)

Issue

save_only_link is the one-box path for a link whose kind has its feature switched off, and it
is what the plugin surface publishes as save_web_link. It strips the value, asks
link_services.settle(kind, "", typed, label) for a service and a name, and saves:

# web_links.py:314-317
if primary.url != typed:
    primary.service, primary.label = link_services.settle(kind, "", typed, primary.label)
primary.url = typed
primary.save()

Its own docstring says of the service step "That can refuse nothing", and nothing else on the
path refuses either: no full_clean, so the URLField validator never runs, and no
addresses.web_address(), the check the API (api/schemas.py:36), the core's vCard import
(jobs/vcards.py:520) and the doors closed by #770 apply. A value with a javascript:, data:
or file: scheme is stored as is and rendered as href on the company page and the documents
page.

The core's own callers are safe by their own means: the CV importer runs _clean_website()
(resume/importing.py:173-194, #610) before the call at :293, and seed_demo.py:281 writes
fixed addresses. The plugin surface is where the gap is live: postulo-dav's apply_card hands
save_web_link a card's URL property read from another server, unchecked (the card-side
issue of this review on postulo/postulo-dav), and any future plugin on the surface inherits the
same hole. #770's walk closed "the importer, the kind files, the update check"; this door was
not on its list.

Impact

A plugin that feeds save_web_link text from another server stores a link with a non-web
scheme on the account holder's own pages. The content security policy (script-src 'self',
no unsafe-inline) stops a javascript: link from running in a conforming browser, and
browsers refuse top-level data: navigation, so the exploit is thin; the weakness is that the
one function every plugin uses to write a link is the one that does not check what the others
do. Scope is the account holder's own account. Low, as #770 rated the same weakness at the other
doors.

Fix

Make save_only_link refuse what web_address refuses, so every present and future caller is
covered:

# web_links.py, after typed = (typed or "").strip()
if typed:
    try:
        typed = addresses.web_address(typed)
    except ValueError as exc:
        raise ValidationError(str(exc), code="address") from exc

Both existing core callers already pass values the check accepts, so neither changes. Record
the refusal on the plugin surface's docstring for save_web_link ("raises ValidationError
for anything that is not a complete http(s) address"), so a plugin knows to catch it and note
the card rather than fail the run.

Done when a test in tests/test_web_links.py calls save_only_link with a javascript:, a
data: and a file: address and asserts a ValidationError and no WebLink row, and
tests/test_hardening_770.py gains the case for this door beside the others.

#770 (closed, PST-041): the walk that closed the other doors; this is the one it did not reach.
#610 (closed): the same gap for the CV importer, closed in the importer rather than here.
postulo/postulo-dav (the card-side issue of this review): the plugin keeps its own two-line
scheme check whatever the core does, since addresses.web_address is not on the plugin surface.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Input Validation · **Reference:** CWE-20 (Improper Input Validation); CWE-79 (Improper Neutralization of Input During Web Page Generation); OWASP A03:2021 (Injection) **Location:** - `src/postulo/core/web_links.py:280-317` (`save_only_link`: `typed = (typed or "").strip()`, then `primary.url = typed; primary.save()`) - `src/postulo/core/link_services.py:471-501` (`settle`: with nothing chosen, `guess()` or `("", label)`; it refuses no address) - `src/postulo/plugins/api.py:116,277,357` (`save_web_link` on the plugin surface is `save_only_link`) - `src/postulo/resume/importing.py:293` and `src/postulo/core/management/commands/seed_demo.py:281` (the core's own callers) - `src/postulo/core/models.py:913` (`WebLink.url`, a `URLField(max_length=500)` whose validator runs only on `full_clean`) - `src/postulo/templates/jobs/company_detail.html:159,500`, `src/postulo/templates/documents/application_documents.html:68` (`<a href="{{ link.url }}" ...>`) ## Issue `save_only_link` is the one-box path for a link whose kind has its feature switched off, and it is what the plugin surface publishes as `save_web_link`. It strips the value, asks `link_services.settle(kind, "", typed, label)` for a service and a name, and saves: ```python # web_links.py:314-317 if primary.url != typed: primary.service, primary.label = link_services.settle(kind, "", typed, primary.label) primary.url = typed primary.save() ``` Its own docstring says of the service step "That can refuse nothing", and nothing else on the path refuses either: no `full_clean`, so the `URLField` validator never runs, and no `addresses.web_address()`, the check the API (`api/schemas.py:36`), the core's vCard import (`jobs/vcards.py:520`) and the doors closed by #770 apply. A value with a `javascript:`, `data:` or `file:` scheme is stored as is and rendered as `href` on the company page and the documents page. The core's own callers are safe by their own means: the CV importer runs `_clean_website()` (`resume/importing.py:173-194`, #610) before the call at `:293`, and `seed_demo.py:281` writes fixed addresses. The plugin surface is where the gap is live: postulo-dav's `apply_card` hands `save_web_link` a card's `URL` property read from another server, unchecked (the card-side issue of this review on postulo/postulo-dav), and any future plugin on the surface inherits the same hole. #770's walk closed "the importer, the kind files, the update check"; this door was not on its list. ## Impact A plugin that feeds `save_web_link` text from another server stores a link with a non-web scheme on the account holder's own pages. The content security policy (`script-src 'self'`, no `unsafe-inline`) stops a `javascript:` link from running in a conforming browser, and browsers refuse top-level `data:` navigation, so the exploit is thin; the weakness is that the one function every plugin uses to write a link is the one that does not check what the others do. Scope is the account holder's own account. Low, as #770 rated the same weakness at the other doors. ## Fix Make `save_only_link` refuse what `web_address` refuses, so every present and future caller is covered: ```python # web_links.py, after typed = (typed or "").strip() if typed: try: typed = addresses.web_address(typed) except ValueError as exc: raise ValidationError(str(exc), code="address") from exc ``` Both existing core callers already pass values the check accepts, so neither changes. Record the refusal on the plugin surface's docstring for `save_web_link` ("raises `ValidationError` for anything that is not a complete http(s) address"), so a plugin knows to catch it and note the card rather than fail the run. Done when a test in `tests/test_web_links.py` calls `save_only_link` with a `javascript:`, a `data:` and a `file:` address and asserts a `ValidationError` and no `WebLink` row, and `tests/test_hardening_770.py` gains the case for this door beside the others. ## Related #770 (closed, PST-041): the walk that closed the other doors; this is the one it did not reach. #610 (closed): the same gap for the CV importer, closed in the importer rather than here. postulo/postulo-dav (the card-side issue of this review): the plugin keeps its own two-line scheme check whatever the core does, since `addresses.web_address` is not on the plugin surface.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:39:41 +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#991
No description provided.