SEC2-020: Capture notifications build their links from the request's Host rather than from POSTULO_PUBLIC_URL, undoing the rule #747 set #962

Closed
opened 2026-10-17 10:38:26 +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: Configuration · Reference: CWE-644 (Improper Neutralization of HTTP Headers for Scripting Syntax) / CWE-20 (Improper Input Validation); OWASP A05:2021 (Security Misconfiguration)

Location:

  • src/postulo/notifications/slow.py:62-71 (link)
  • src/postulo/api/api.py:580 and :595 (base=request.build_absolute_uri("/"))
  • src/postulo/notifications/base.py:182-197 (absolute_url, the rule)

Issue

absolute_url was changed for #747 so that the operator's POSTULO_PUBLIC_URL wins over whatever the request's Host header claims:

    base = (getattr(settings, "POSTULO_PUBLIC_URL", "") or "").rstrip("/")
    # The address the operator named wins over the one the request claims (#747): a Host header
    # is the sender's to choose, and a link in a message goes to whoever receives it.
    if base:
        return f"{base}{path}"
    if request is not None:
        return request.build_absolute_uri(path)

The errand that delivers the two capture notifications does the opposite. api.py stores base=request.build_absolute_uri("/") in the errand payload, and slow.link prefers that stored value and only falls back to absolute_url when it is empty (its docstring still describes that older precedence):

def link(payload: dict, path: str) -> str:
    base = (payload.get("base") or "").rstrip("/")
    return f"{base}{path}" if base else absolute_url(path)

So for capture_received and capture_batch the link in the mail, the webhook payload and the browser push is the request's Host even on an instance whose operator set POSTULO_PUBLIC_URL precisely to stop that. The precondition is the one #747 named: POSTULO_ALLOWED_HOSTS broad enough that more than one Host validates (prod refuses a bare * unless POSTULO_ALLOW_WILDCARD_HOSTS=true, config/settings/prod.py:25-29, but a wildcard suffix or an over-long list is not refused). The browser notice itself is harmless: static/js/app.js:7074 and sw.js rewrite a foreign-origin URL to / before opening it. The mail body (plugins/email/__init__.py:75-81) and the webhook url field carry the link as built.

Impact

Someone holding a captures-scoped token (the person's own extension, or whoever took its token) can, on a loosely configured instance, make the person's own notification mail and webhook payload point at a host other than the operator's, which is the phishing shape #747 described. It needs the token and the misconfiguration, so it is Low; but it is a regression of a rule the project wrote down and tests for elsewhere.

Fix

Make link apply the same precedence as absolute_url: POSTULO_PUBLIC_URL when set, else the payload's base, else the bare path; and rewrite its docstring so it no longer describes the old order.

def link(payload: dict, path: str) -> str:
    public = (getattr(settings, "POSTULO_PUBLIC_URL", "") or "").rstrip("/")
    if public:
        return f"{public}{path}"
    base = (payload.get("base") or "").rstrip("/")
    return f"{base}{path}" if base else absolute_url(path)

Done when a test beside tests/test_hardening_747.py drives deliver() with a payload whose base is a second allowed host while POSTULO_PUBLIC_URL names the first, and asserts the delivered Notification.url starts with POSTULO_PUBLIC_URL.

#747 (closed) - fixed absolute_url and the settings check; this call site keeps the old precedence, so it is an incomplete fix rather than a new issue. #247 (closed) - introduced the base carried in the errand.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Configuration · **Reference:** CWE-644 (Improper Neutralization of HTTP Headers for Scripting Syntax) / CWE-20 (Improper Input Validation); OWASP A05:2021 (Security Misconfiguration) **Location:** - `src/postulo/notifications/slow.py:62-71` (`link`) - `src/postulo/api/api.py:580` and `:595` (`base=request.build_absolute_uri("/")`) - `src/postulo/notifications/base.py:182-197` (`absolute_url`, the rule) ## Issue `absolute_url` was changed for #747 so that the operator's `POSTULO_PUBLIC_URL` wins over whatever the request's `Host` header claims: ```python base = (getattr(settings, "POSTULO_PUBLIC_URL", "") or "").rstrip("/") # The address the operator named wins over the one the request claims (#747): a Host header # is the sender's to choose, and a link in a message goes to whoever receives it. if base: return f"{base}{path}" if request is not None: return request.build_absolute_uri(path) ``` The errand that delivers the two capture notifications does the opposite. `api.py` stores `base=request.build_absolute_uri("/")` in the errand payload, and `slow.link` prefers that stored value and only falls back to `absolute_url` when it is empty (its docstring still describes that older precedence): ```python def link(payload: dict, path: str) -> str: base = (payload.get("base") or "").rstrip("/") return f"{base}{path}" if base else absolute_url(path) ``` So for `capture_received` and `capture_batch` the link in the mail, the webhook payload and the browser push is the request's Host even on an instance whose operator set `POSTULO_PUBLIC_URL` precisely to stop that. The precondition is the one #747 named: `POSTULO_ALLOWED_HOSTS` broad enough that more than one Host validates (prod refuses a bare `*` unless `POSTULO_ALLOW_WILDCARD_HOSTS=true`, `config/settings/prod.py:25-29`, but a wildcard suffix or an over-long list is not refused). The browser notice itself is harmless: `static/js/app.js:7074` and `sw.js` rewrite a foreign-origin URL to `/` before opening it. The mail body (`plugins/email/__init__.py:75-81`) and the webhook `url` field carry the link as built. ## Impact Someone holding a captures-scoped token (the person's own extension, or whoever took its token) can, on a loosely configured instance, make the person's own notification mail and webhook payload point at a host other than the operator's, which is the phishing shape #747 described. It needs the token and the misconfiguration, so it is Low; but it is a regression of a rule the project wrote down and tests for elsewhere. ## Fix Make `link` apply the same precedence as `absolute_url`: `POSTULO_PUBLIC_URL` when set, else the payload's base, else the bare path; and rewrite its docstring so it no longer describes the old order. ```python def link(payload: dict, path: str) -> str: public = (getattr(settings, "POSTULO_PUBLIC_URL", "") or "").rstrip("/") if public: return f"{public}{path}" base = (payload.get("base") or "").rstrip("/") return f"{base}{path}" if base else absolute_url(path) ``` Done when a test beside `tests/test_hardening_747.py` drives `deliver()` with a payload whose `base` is a second allowed host while `POSTULO_PUBLIC_URL` names the first, and asserts the delivered `Notification.url` starts with `POSTULO_PUBLIC_URL`. ## Related #747 (closed) - fixed `absolute_url` and the settings check; this call site keeps the old precedence, so it is an incomplete fix rather than a new issue. #247 (closed) - introduced the `base` carried in the errand.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:38:28 +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#962
No description provided.