SEC2-057: A URL pulled from a card is stored as a contact's web link with no scheme check, and pages render it as href #32
Labels
No labels
bug
documentation
enhancement
performance
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-dav#32
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
postulo-davat1d49d6e, checked by a second, independent pass.Severity: Low · Category: Injection · Reference: CWE-79 (Improper Neutralization of Input During Web Page Generation); CWE-20 (Improper Input Validation); OWASP A03:2021 (Injection)
Location:
src/postulo_dav/text.py:198-199(text_to_card:URLtaken asprop.value.strip(); theitem1.group prefix is dropped attext.py:83-85, so a groupedURLlands here too)src/postulo_dav/sync.py:165(apply_card:save_web_link(contact, contact.owner, LinkKind.SOCIAL, card.url[:500])), reached fromsync.py:472-493(a changed twin) andsync.py:533-542(adoption)src/postulo/core/web_links.py:280-317(save_only_link, whichsave_web_linkis:primary.url = typed; primary.save(), nofull_clean, noaddresses.web_address)src/postulo/templates/jobs/company_detail.html:159,500(<a href="{{ link.url }}" ...>for a contact's links)Issue
The vCard reader takes the card's
URLproperty verbatim andapply_cardhands it to thesurface call
save_web_link. In the core that issave_only_link: it assignsprimary.url = typedand saves, without theURLFieldvalidation a form would run and without theaddresses.web_address()scheme check that the API, the archive importer (sincepostulo/postulo#770) and the core's own vCard import (
jobs/vcards.py:520) apply. The storedvalue is then written into
hrefon the company page for every link the contact has. A cardwhose
URLcarries a non-web scheme therefore reaches the page as a clickable address.The core's own vCard reader has a test proving it drops
javascript:,file:anddata:addresses (
tests/security/test_vcard.py:118-121); this plugin's reader is a separate copy withno such refusal and no such test.
The other properties are bounded or harmless on this path:
role[:200],email[:254](rendered only as text and after a
mailto:prefix),phone[:40]; the name and company lengthproblem is #20.
Impact
Someone who can place or edit a card in the collection the connection reads: the account
holder's own address book on their DAV server, which on a multi-user server (Nextcloud lists
address books shared into a person's home under the sharer's display name, and #13 notes a
typed name can select someone else's book) another user of that server may be able to write to.
The result is a stored link with a script or data scheme in the victim's own Postulo session.
The content security policy allows no inline script and browsers refuse top-level
data:navigation, which keeps the exploit thin, but the core judged the same weakness worth closing at
every other door (postulo/postulo#770, Low). Scope is the account holder's own account.
Fix
Accept only a complete http(s) address and drop the rest with a report note. The check belongs
in
text_to_card, so thatCard.urlis already clean fordigest_source()and the conflictnote, or at the top of
apply_card:addresses.web_addressis not on the plugin surface, so the plugin keeps this two-line check ofits own whatever the core does.
Done when a test in
tests/test_dav.pypulls a card whoseURLis not http(s), in both the plainURL:and the groupeditem1.URL:form that phones write, and asserts that noWebLinkrowcarries it and that the report says so.
Related
#27 (open) is the writer's escaping of
URL; #13 (open) is the hrefs of DAV resources; #2(closed) item 3 is the connection URL's scheme. postulo/postulo#770 (closed) closed the other
doors into
WebLink.urland did not look at the plugin surface'ssave_only_link;postulo/postulo#610 (closed) was the same gap for the CV importer. The core half,
save_only_linkrefusing what
web_addressrefuses for every caller, is filed as a companion issue onpostulo/postulo.
Fixed by pull request #35, merged as
fb8bbd7ae.