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

Closed
opened 2026-10-17 10:39:50 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-10-16 in a read-only security review of postulo-dav at 1d49d6e, 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: URL taken as prop.value.strip(); the item1. group prefix is dropped at text.py:83-85, so a grouped URL lands here too)
  • src/postulo_dav/sync.py:165 (apply_card: save_web_link(contact, contact.owner, LinkKind.SOCIAL, card.url[:500])), reached from sync.py:472-493 (a changed twin) and sync.py:533-542 (adoption)
  • core src/postulo/core/web_links.py:280-317 (save_only_link, which save_web_link is: primary.url = typed; primary.save(), no full_clean, no addresses.web_address)
  • core 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 URL property verbatim and apply_card hands it to the
surface call save_web_link. In the core that is save_only_link: it assigns primary.url = typed and saves, without the URLField validation a form would run and without the
addresses.web_address() scheme check that the API, the archive importer (since
postulo/postulo#770) and the core's own vCard import (jobs/vcards.py:520) apply. The stored
value is then written into href on the company page for every link the contact has. A card
whose URL carries 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: and data:
addresses (tests/security/test_vcard.py:118-121); this plugin's reader is a separate copy with
no 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 length
problem 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 that Card.url is already clean for digest_source() and the conflict
note, or at the top of apply_card:

from urllib.parse import urlsplit

url = (card.url or "")[:500].strip()
if url and urlsplit(url).scheme.lower() not in ("http", "https"):
    url = ""  # and add a report note naming the card

addresses.web_address is not on the plugin surface, so the plugin keeps this two-line check of
its own whatever the core does.

Done when a test in tests/test_dav.py pulls a card whose URL is not http(s), in both the plain
URL: and the grouped item1.URL: form that phones write, and asserts that no WebLink row
carries it and that the report says so.

#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.url and did not look at the plugin surface's save_only_link;
postulo/postulo#610 (closed) was the same gap for the CV importer. The core half, save_only_link
refusing what web_address refuses for every caller, is filed as a companion issue on
postulo/postulo.

Found on 2026-10-16 in a read-only security review of `postulo-dav` at `1d49d6e`, 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`: `URL` taken as `prop.value.strip()`; the `item1.` group prefix is dropped at `text.py:83-85`, so a grouped `URL` lands here too) - `src/postulo_dav/sync.py:165` (`apply_card`: `save_web_link(contact, contact.owner, LinkKind.SOCIAL, card.url[:500])`), reached from `sync.py:472-493` (a changed twin) and `sync.py:533-542` (adoption) - core `src/postulo/core/web_links.py:280-317` (`save_only_link`, which `save_web_link` is: `primary.url = typed; primary.save()`, no `full_clean`, no `addresses.web_address`) - core `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 `URL` property verbatim and `apply_card` hands it to the surface call `save_web_link`. In the core that is `save_only_link`: it assigns `primary.url = typed` and saves, without the `URLField` validation a form would run and without the `addresses.web_address()` scheme check that the API, the archive importer (since postulo/postulo#770) and the core's own vCard import (`jobs/vcards.py:520`) apply. The stored value is then written into `href` on the company page for every link the contact has. A card whose `URL` carries 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:` and `data:` addresses (`tests/security/test_vcard.py:118-121`); this plugin's reader is a separate copy with no 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 length problem 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 that `Card.url` is already clean for `digest_source()` and the conflict note, or at the top of `apply_card`: ```python from urllib.parse import urlsplit url = (card.url or "")[:500].strip() if url and urlsplit(url).scheme.lower() not in ("http", "https"): url = "" # and add a report note naming the card ``` `addresses.web_address` is not on the plugin surface, so the plugin keeps this two-line check of its own whatever the core does. Done when a test in `tests/test_dav.py` pulls a card whose `URL` is not http(s), in both the plain `URL:` and the grouped `item1.URL:` form that phones write, and asserts that no `WebLink` row carries 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.url` and did not look at the plugin surface's `save_only_link`; postulo/postulo#610 (closed) was the same gap for the CV importer. The core half, `save_only_link` refusing what `web_address` refuses for every caller, is filed as a companion issue on postulo/postulo.
Author
Owner

Fixed by pull request #35, merged as fb8bbd7ae.

Fixed by pull request #35, merged as fb8bbd7ae.
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-dav#32
No description provided.