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
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#991
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: 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(), thenprimary.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_linkon the plugin surface issave_only_link)src/postulo/resume/importing.py:293andsrc/postulo/core/management/commands/seed_demo.py:281(the core's own callers)src/postulo/core/models.py:913(WebLink.url, aURLField(max_length=500)whose validator runs only onfull_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_linkis the one-box path for a link whose kind has its feature switched off, and itis what the plugin surface publishes as
save_web_link. It strips the value, askslink_services.settle(kind, "", typed, label)for a service and a name, and saves:Its own docstring says of the service step "That can refuse nothing", and nothing else on the
path refuses either: no
full_clean, so theURLFieldvalidator never runs, and noaddresses.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 ajavascript:,data:or
file:scheme is stored as is and rendered ashrefon the company page and the documentspage.
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, andseed_demo.py:281writesfixed addresses. The plugin surface is where the gap is live: postulo-dav's
apply_cardhandssave_web_linka card'sURLproperty read from another server, unchecked (the card-sideissue 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_linktext from another server stores a link with a non-webscheme on the account holder's own pages. The content security policy (
script-src 'self',no
unsafe-inline) stops ajavascript:link from running in a conforming browser, andbrowsers refuse top-level
data:navigation, so the exploit is thin; the weakness is that theone 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_linkrefuse whatweb_addressrefuses, so every present and future caller iscovered:
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("raisesValidationErrorfor 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.pycallssave_only_linkwith ajavascript:, adata:and afile:address and asserts aValidationErrorand noWebLinkrow, andtests/test_hardening_770.pygains 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_addressis not on the plugin surface.