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.255blocked (in 172.16.0.0/12) but 172.32.0.1ALLOWED (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.
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #203.
Problem
The webhook SSRF "defense" in
pass6_action_securitywas 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— newpub(crate) fn is_blocked_webhook_target(ip: IpAddr) -> booldoing real range checks:0.0.0.0/8, loopback127/8, RFC1918 (is_private), link-local169.254/16(subsumes the169.254.169.254metadata IP — not hardcoded), multicast, broadcast.::1, unspecified::, multicastff00::/8, plus manual bit-masks for ULAfc00::/7and link-localfe80::/10.::ffff:a.b.c.d) is unwrapped FIRST viato_ipv4_mapped()and re-checked against the IPv4 rules, so[::ffff:127.0.0.1]cannot bypass loopback.CallWebhookarm now parses the URL withreqwest::Urland 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 indispatch_with_optionsbefore the POST: resolves the host viatokio::net::lookup_hostand rejects if any resolved address is blocked. The blocking decision is factored intofirst_blocked_address(&[IpAddr])so it is unit-tested against synthetic IP lists with no live lookup.block_private_webhooksdoc 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_localare not stable on the pinned toolchain (1.97.1).to_ipv4_mapped()is stable (1.63+).Exact CIDR math
Tested boundaries:
172.31.255.255blocked (in172.16.0.0/12) but172.32.0.1ALLOWED (just outside) — proves it is not a naive172.prefix.::ffff:127.0.0.1is 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
reqwestDNS resolver / connect override) — out of scope here, flagged as a follow-up.No new dependency
Uses
reqwest::Url(already a transitive dep) andstd::netonly.Gate output
Relevant crate:
nuncio_filterunit tests 26 -> 32 (+6): predicate block/allow cases, pass6 literal-IP bypass cases (RFC1918 + IPv6 + IPv4-mapped), pass6 public-IP allow, and the twofirst_blocked_addresshelper tests. Existingtest_blocked_private_ip_webhookandtest_pass6_action_security_validationstill pass unchanged. No live network/DNS in any test.🤖 Generated with Claude Code