fix(filter): enforce real IP/CIDR SSRF checks on webhook targets #243

Merged
KofTwentyTwo merged 1 commits from story/m6-webhook-ssrf into dev 2026-07-30 18:10:07 +00:00
KofTwentyTwo commented 2026-07-30 18:04:36 +00:00 (Migrated from github.com)

Closes #203.

Problem

The webhook SSRF "defense" in pass6_action_security was a case-insensitive substring match on the raw URL (127.0.0.1 / localhost / 169.254.169.254 / 0.0.0.0). It was trivially bypassed by RFC1918 (10/8, 172.16/12, 192.168/16), other link-local addresses, alternate IP-literal encodings, IPv6 ([::1], [fc00::1], [fe80::1]), and any DNS name resolving to a private IP.

Changes

  • validator.rs — new pub(crate) fn is_blocked_webhook_target(ip: IpAddr) -> bool doing real range checks:
    • IPv4: 0.0.0.0/8, loopback 127/8, RFC1918 (is_private), link-local 169.254/16 (subsumes the 169.254.169.254 metadata IP — not hardcoded), multicast, broadcast.
    • IPv6: loopback ::1, unspecified ::, multicast ff00::/8, plus manual bit-masks for ULA fc00::/7 and link-local fe80::/10.
    • IPv4-mapped IPv6 (::ffff:a.b.c.d) is unwrapped FIRST via to_ipv4_mapped() and re-checked against the IPv4 rules, so [::ffff:127.0.0.1] cannot bypass loopback.
    • The CallWebhook arm now parses the URL with reqwest::Url and rejects blocked literal-IP hosts (scheme check preserved unchanged). Hostnames are left to the dispatcher (pass 6 is synchronous).
  • webhook.rs — dispatch-time DNS check in dispatch_with_options before the POST: resolves the host via tokio::net::lookup_host and rejects if any resolved address is blocked. The blocking decision is factored into first_blocked_address(&[IpAddr]) so it is unit-tested against synthetic IP lists with no live lookup.
  • Corrected the misleading block_private_webhooks doc comment (it claimed DNS blacklisting that did not exist).

IPv6 approach

Used stable helpers for loopback/unspecified/multicast, and manual bit-masking for ULA/link-local because Ipv6Addr::is_unique_local/is_unicast_link_local are not stable on the pinned toolchain (1.97.1). to_ipv4_mapped() is stable (1.63+).

Exact CIDR math

Tested boundaries: 172.31.255.255 blocked (in 172.16.0.0/12) but 172.32.0.1 ALLOWED (just outside) — proves it is not a naive 172. prefix. ::ffff:127.0.0.1 is blocked via the mapped-unwrap path.

Known limitation (TOCTOU — follow-up candidate)

The dispatch-time check resolves + validates, then reqwest independently re-resolves and connects. A narrow DNS-rebind window remains between our lookup and the actual connection. Documented in-code; full closure needs a pinned resolver/connector (custom reqwest DNS resolver / connect override) — out of scope here, flagged as a follow-up.

No new dependency

Uses reqwest::Url (already a transitive dep) and std::net only.

Gate output

cargo fmt --all -- --check      # clean
cargo check-all                  # Finished, zero warnings
cargo test-all                   # all green, 0 ignored

Relevant crate: nuncio_filter unit tests 26 -> 32 (+6): predicate block/allow cases, pass6 literal-IP bypass cases (RFC1918 + IPv6 + IPv4-mapped), pass6 public-IP allow, and the two first_blocked_address helper tests. Existing test_blocked_private_ip_webhook and test_pass6_action_security_validation still pass unchanged. No live network/DNS in any test.

🤖 Generated with Claude Code

Closes #203. ## Problem The webhook SSRF "defense" in `pass6_action_security` was a case-insensitive **substring** match on the raw URL (`127.0.0.1` / `localhost` / `169.254.169.254` / `0.0.0.0`). It was trivially bypassed by RFC1918 (`10/8`, `172.16/12`, `192.168/16`), other link-local addresses, alternate IP-literal encodings, IPv6 (`[::1]`, `[fc00::1]`, `[fe80::1]`), and any DNS name resolving to a private IP. ## Changes - **`validator.rs`** — new `pub(crate) fn is_blocked_webhook_target(ip: IpAddr) -> bool` doing real range checks: - IPv4: `0.0.0.0/8`, loopback `127/8`, RFC1918 (`is_private`), link-local `169.254/16` (subsumes the `169.254.169.254` metadata IP — not hardcoded), multicast, broadcast. - IPv6: loopback `::1`, unspecified `::`, multicast `ff00::/8`, plus **manual bit-masks** for ULA `fc00::/7` and link-local `fe80::/10`. - **IPv4-mapped IPv6 (`::ffff:a.b.c.d`) is unwrapped FIRST** via `to_ipv4_mapped()` and re-checked against the IPv4 rules, so `[::ffff:127.0.0.1]` cannot bypass loopback. - The `CallWebhook` arm now parses the URL with `reqwest::Url` and rejects blocked **literal-IP** hosts (scheme check preserved unchanged). Hostnames are left to the dispatcher (pass 6 is synchronous). - **`webhook.rs`** — dispatch-time DNS check in `dispatch_with_options` before the POST: resolves the host via `tokio::net::lookup_host` and rejects if any resolved address is blocked. The blocking decision is factored into `first_blocked_address(&[IpAddr])` so it is unit-tested against synthetic IP lists with **no live lookup**. - Corrected the misleading `block_private_webhooks` doc comment (it claimed DNS blacklisting that did not exist). ## IPv6 approach Used **stable helpers** for loopback/unspecified/multicast, and **manual bit-masking** for ULA/link-local because `Ipv6Addr::is_unique_local`/`is_unicast_link_local` are not stable on the pinned toolchain (1.97.1). `to_ipv4_mapped()` is stable (1.63+). ## Exact CIDR math Tested boundaries: `172.31.255.255` **blocked** (in `172.16.0.0/12`) but `172.32.0.1` **ALLOWED** (just outside) — proves it is not a naive `172.` prefix. `::ffff:127.0.0.1` is **blocked** via the mapped-unwrap path. ## Known limitation (TOCTOU — follow-up candidate) The dispatch-time check resolves + validates, then reqwest independently re-resolves and connects. A narrow DNS-rebind window remains between our lookup and the actual connection. Documented in-code; full closure needs a pinned resolver/connector (custom `reqwest` DNS resolver / connect override) — out of scope here, flagged as a follow-up. ## No new dependency Uses `reqwest::Url` (already a transitive dep) and `std::net` only. ## Gate output ``` cargo fmt --all -- --check # clean cargo check-all # Finished, zero warnings cargo test-all # all green, 0 ignored ``` Relevant crate: `nuncio_filter` unit tests 26 -> 32 (+6): predicate block/allow cases, pass6 literal-IP bypass cases (RFC1918 + IPv6 + IPv4-mapped), pass6 public-IP allow, and the two `first_blocked_address` helper tests. Existing `test_blocked_private_ip_webhook` and `test_pass6_action_security_validation` still pass unchanged. No live network/DNS in any test. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.