PST-050: POSTULO_CONNECTIONS_ALLOW_PRIVATE is one global switch that also disables address pinning and exposes loopback and link-local to every member #779

Closed
opened 2026-10-10 10:56:23 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-050. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: ssrf-policy-scope
  • Location: src/postulo/plugins/http.py (106-110,133-152)
  • Found by: SSRF and outbound traffic

Who can do it, and what they need. A non-administrator account on an instance whose operator set POSTULO_CONNECTIONS_ALLOW_PRIVATE=true (documented as the way to let a connection reach a Paperless or mail relay on the LAN). Precondition: that switch is on.

How it works.

  1. The operator enables the switch to let one plugin reach one LAN host.
  2. Any user creates a webhook connection with url=http://169.254.169.254/latest/meta-data/ or http://127.0.0.1:6379/, http://10.0.0.1/ etc.; validate() -> check_destination returns at once (line 108), _guard returns at once (line 147), so neither the scheme nor the address is checked and the name is not pinned (a rebinding name is now free to flip between lookups).
  3. Pressing Test (10/h) makes the server POST there and the page shows Answered 200., The receiver answered 403., ConnectError: ... or, on a 3xx, the receiver's full Location header (redirect_message, lines 253-260), a per-host, per-port open/closed oracle plus one header of response content.
  4. The same user may put a LAN host and any port into the own-mail connection: check_connection is called with allow_private=destinations.private_allowed() (own_mail/init.py:190), and the test echoes f"{type(error).__name__}: {error}" including the remote banner/SMTP reply (core/mail.py:131).

Impact. Where the switch is on, every account on the instance can map the operator's private network (hosts, open ports, redirect targets, SMTP banners) through the server, including a cloud metadata endpoint. This is explicitly accepted by the threat model as the operator's decision, but the decision is all-or-nothing: there is no way to allow one destination or one CIDR for one plugin without opening everything for everybody, and enabling it also removes pinning and the http/https scheme check for the connection client.

Evidence.

src/postulo/plugins/http.py:106-110 `def check_destination(url): if private_destinations_allowed(): return`
src/postulo/plugins/http.py:147-148 `if private_destinations_allowed(): return` (in `_guard`, before any resolution or pinning)
src/postulo/plugins/webhook/__init__.py:257-259 `location = response.headers.get("Location", "")` ... `_("The receiver redirected to %(location)s; use that address instead.")`
src/postulo/plugins/own_mail/__init__.py:190 `allow_private=destinations.private_allowed(),`
src/postulo/config/settings/base.py:649 `POSTULO_CONNECTIONS_ALLOW_PRIVATE = env.bool(...)`

Verification (verdict: confirmed; severity after verification: Low). Accept only: when an operator enables POSTULO_CONNECTIONS_ALLOW_PRIVATE on an instance with more than one account, every account can map the operator's private network through the Test buttons (open/closed, HTTP status, redirect Location, SMTP banner). Remove the pinning/rebinding and scheme-check claims (no effect when all addresses are allowed). It is an accepted operator-level trade-off under THREAT-MODEL rule 5, so report as a design limitation / documentation gap (state in .env.example and the wiki that the switch applies to every account; offer a per-host or CIDR allowlist). Cited lines http.py:106-110, 133-152 and webhook/init.py:253-260 are accurate.

Suggested remediation. Replace the boolean with an allowlist (hostnames/CIDRs, optionally per plugin) so that allowing a LAN Paperless does not allow 169.254.169.254 and 127.0.0.1 for every user; keep pinning and the scheme check on even when private addresses are allowed; always refuse the metadata and loopback ranges unless named explicitly; and do not echo the Location header to the user when the destination is private.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-050**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** ssrf-policy-scope - **Location:** `src/postulo/plugins/http.py` (106-110,133-152) - **Found by:** SSRF and outbound traffic **Who can do it, and what they need.** A non-administrator account on an instance whose operator set POSTULO_CONNECTIONS_ALLOW_PRIVATE=true (documented as the way to let a connection reach a Paperless or mail relay on the LAN). Precondition: that switch is on. **How it works.** 1. The operator enables the switch to let one plugin reach one LAN host. 2. Any user creates a webhook connection with `url=http://169.254.169.254/latest/meta-data/` or `http://127.0.0.1:6379/`, `http://10.0.0.1/` etc.; `validate()` -> `check_destination` returns at once (line 108), `_guard` returns at once (line 147), so neither the scheme nor the address is checked and the name is not pinned (a rebinding name is now free to flip between lookups). 3. Pressing Test (10/h) makes the server POST there and the page shows `Answered 200.`, `The receiver answered 403.`, `ConnectError: ...` or, on a 3xx, the receiver's full `Location` header (`redirect_message`, lines 253-260), a per-host, per-port open/closed oracle plus one header of response content. 4. The same user may put a LAN host and any port into the own-mail connection: `check_connection` is called with `allow_private=destinations.private_allowed()` (own_mail/__init__.py:190), and the test echoes `f"{type(error).__name__}: {error}"` including the remote banner/SMTP reply (core/mail.py:131). **Impact.** Where the switch is on, every account on the instance can map the operator's private network (hosts, open ports, redirect targets, SMTP banners) through the server, including a cloud metadata endpoint. This is explicitly accepted by the threat model as the operator's decision, but the decision is all-or-nothing: there is no way to allow one destination or one CIDR for one plugin without opening everything for everybody, and enabling it also removes pinning and the http/https scheme check for the connection client. **Evidence.** ```text src/postulo/plugins/http.py:106-110 `def check_destination(url): if private_destinations_allowed(): return` src/postulo/plugins/http.py:147-148 `if private_destinations_allowed(): return` (in `_guard`, before any resolution or pinning) src/postulo/plugins/webhook/__init__.py:257-259 `location = response.headers.get("Location", "")` ... `_("The receiver redirected to %(location)s; use that address instead.")` src/postulo/plugins/own_mail/__init__.py:190 `allow_private=destinations.private_allowed(),` src/postulo/config/settings/base.py:649 `POSTULO_CONNECTIONS_ALLOW_PRIVATE = env.bool(...)` ``` **Verification** (verdict: confirmed; severity after verification: Low). Accept only: when an operator enables POSTULO_CONNECTIONS_ALLOW_PRIVATE on an instance with more than one account, every account can map the operator's private network through the Test buttons (open/closed, HTTP status, redirect Location, SMTP banner). Remove the pinning/rebinding and scheme-check claims (no effect when all addresses are allowed). It is an accepted operator-level trade-off under THREAT-MODEL rule 5, so report as a design limitation / documentation gap (state in .env.example and the wiki that the switch applies to every account; offer a per-host or CIDR allowlist). Cited lines http.py:106-110, 133-152 and webhook/__init__.py:253-260 are accurate. **Suggested remediation.** Replace the boolean with an allowlist (hostnames/CIDRs, optionally per plugin) so that allowing a LAN Paperless does not allow 169.254.169.254 and 127.0.0.1 for every user; keep pinning and the scheme check on even when private addresses are allowed; always refuse the metadata and loopback ranges unless named explicitly; and do not echo the Location header to the user when the destination is private.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:51 +00:00
Author
Owner

Fixed in 298248f53 on main.

Fixed in 298248f53 on main.
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#779
No description provided.