PST-087: Multicast ranges are classified as public by the destination guard #816

Closed
opened 2026-10-10 10:56:54 +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-087. Severity: Informational.

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: Informational
  • Category: ssrf-guard-gap / address-classification
  • Location: src/postulo/core/destinations.py (119-146)
  • Found by: SSRF and outbound traffic

Who can do it, and what they need. Any user who can make the server dial an address (capture, link check, logo, webhook). Precondition: none beyond an account. No practical effect today because every outbound protocol here is TCP, which cannot complete a handshake to a multicast destination.

How it works.

  1. Submit http://224.0.0.251/ (mDNS group), http://239.255.255.250/ (SSDP) or http://[ff02::1]/.
  2. public_addresses_for resolves the literal, is_public returns True because Python's ipaddress.is_global is derived from is_private, whose table has no multicast entry (verified: is_public(224.0.0.1) and is_public(ff02::1) are both True in this environment).
  3. httpx sends a TCP SYN to the multicast address on the LAN; no TCP stack answers a multicast SYN, so the request times out. The SYN itself does reach every host on the link, which is a (negligible) link-local emission from a server that promises not to make one.

Impact. No read or write path with TCP. The gap only matters if a future plugin or guard consumer uses UDP (e.g. a future mDNS/SSDP lookup) or if a list of refused ranges is copied from here elsewhere. Recorded because the module says it is 'the one question every guard asks' and the multicast ranges are missing from it.

Verification (verdict: confirmed; severity after verification: Informational). Correct step 3 of the attack path: no TCP SYN is sent to the multicast group; the Linux kernel refuses the connect with ENETUNREACH before building a packet (IPv4 verified in this environment; IPv6 from tcp_v6_connect in kernel source). The only effect today is an error message identical to an unreachable public host. Remediation (described): add an explicit if address.is_multicast: return False (and arguably is_reserved/is_link_local are already covered by is_global) at the top of is_public, and a parametrised case for 224.0.0.251, 239.255.255.250 and ff02::1 in tests/security/test_outbound.py, so a future non-TCP consumer of approve() inherits the refusal.

Suggested remediation. Add address.is_multicast (and is_reserved, is_unspecified for belt and braces) to the refusal in is_public, with a parametrised test for 224.0.0.0/4, 239.255.255.250, ff02::1 and ff0e::1.

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-087**. Severity: **Informational**. 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:** Informational - **Category:** ssrf-guard-gap / address-classification - **Location:** `src/postulo/core/destinations.py` (119-146) - **Found by:** SSRF and outbound traffic **Who can do it, and what they need.** Any user who can make the server dial an address (capture, link check, logo, webhook). Precondition: none beyond an account. No practical effect today because every outbound protocol here is TCP, which cannot complete a handshake to a multicast destination. **How it works.** 1. Submit `http://224.0.0.251/` (mDNS group), `http://239.255.255.250/` (SSDP) or `http://[ff02::1]/`. 2. `public_addresses_for` resolves the literal, `is_public` returns True because Python's `ipaddress.is_global` is derived from `is_private`, whose table has no multicast entry (verified: `is_public(224.0.0.1)` and `is_public(ff02::1)` are both True in this environment). 3. httpx sends a TCP SYN to the multicast address on the LAN; no TCP stack answers a multicast SYN, so the request times out. The SYN itself does reach every host on the link, which is a (negligible) link-local emission from a server that promises not to make one. **Impact.** No read or write path with TCP. The gap only matters if a future plugin or guard consumer uses UDP (e.g. a future mDNS/SSDP lookup) or if a list of refused ranges is copied from here elsewhere. Recorded because the module says it is 'the one question every guard asks' and the multicast ranges are missing from it. **Verification** (verdict: confirmed; severity after verification: Informational). Correct step 3 of the attack path: no TCP SYN is sent to the multicast group; the Linux kernel refuses the connect with ENETUNREACH before building a packet (IPv4 verified in this environment; IPv6 from tcp_v6_connect in kernel source). The only effect today is an error message identical to an unreachable public host. Remediation (described): add an explicit `if address.is_multicast: return False` (and arguably `is_reserved`/`is_link_local` are already covered by is_global) at the top of `is_public`, and a parametrised case for 224.0.0.251, 239.255.255.250 and ff02::1 in tests/security/test_outbound.py, so a future non-TCP consumer of `approve()` inherits the refusal. **Suggested remediation.** Add `address.is_multicast` (and `is_reserved`, `is_unspecified` for belt and braces) to the refusal in `is_public`, with a parametrised test for 224.0.0.0/4, 239.255.255.250, ff02::1 and ff0e::1.
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:17 +00:00
Author
Owner

Fixed in 3657c0941 on main.

Fixed in 3657c0941 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#816
No description provided.