Story for milestone M6. Read docs/STORY-WORKFLOW.md first — it defines the process, gates, and patterns; this issue defines only the specific work. crates/nuncio-filter/src/webhook.rs (WebhookDispatcher) dispatches the CALL WEBHOOK filter action as an outbound HTTP POST. Its "SSRF defense" is implemented in crates/nuncio-filter/src/validator.rs::NsqlValidator::pass6_action_security (validator.rs:271-287). Verified in the tree — the entire check is a case-insensitive substring match on the raw URL string:
This is trivially bypassable: any RFC1918 address (10.x.x.x, 172.16-31.x.x, 192.168.x.x), any other 169.254.0.0/16 link-local address (the substring only catches the one literal cloud-metadata IP, not the whole /16), decimal/octal/hex IP-literal encodings (e.g. http://2130706433/ = 127.0.0.1), http://127.1/, any IPv6 loopback/ULA/link-local address (http://[::1]/, http://[fc00::1]/, http://[fe80::1]/), and any DNS name that merely resolves to a private/loopback IP (DNS rebinding) all pass this check untouched. The ValidationOptions::block_private_webhooks doc comment (validator.rs:36) even claims "Pass 6 pre-flight DNS IP blacklisting for webhooks" — the DNS resolution it describes does not exist in the implementation (reconciles the reported webhook-SSRF substring-blocklist defect).
Goal
Replace the substring blocklist with real IP-literal parsing + CIDR-range checks covering RFC1918, loopback, link-local (which subsumes the cloud-metadata address), IPv6 loopback/ULA/link-local, and unspecified/multicast/broadcast addresses — plus a DNS-resolution step at dispatch time so a hostname that resolves to any of those ranges is also rejected, closing the gap the existing doc comment already claims to close but doesn't.
Files to touch / patterns to mirror
crates/nuncio-filter/src/validator.rs:271-287 (pass6_action_security, CallWebhook arm) — replace the substring check with real parsing:
Parse url with reqwest::Url::parse(url) (no new dependency needed — nuncio-filter already depends on reqwest, Cargo.toml:26, and reqwest::Url is a re-export of the url crate's Url type).
Extract the host via .host_str(). If it parses as a literal IP address (host.parse::<std::net::IpAddr>() succeeds), run it through the new blocked-range predicate (below) and reject if blocked.
If the host is a name (not a literal IP), this sync Pass-6 check cannot resolve DNS (it's not async) — leave hostname-based SSRF-via-DNS-rebind to the dispatch-time check described below; Pass 6 at rule-creation time only needs to catch literal-IP bypasses (which is what the current substring check was trying, and failing, to do).
crates/nuncio-filter/src/validator.rs (new private function, e.g. fn is_blocked_webhook_target(ip: std::net::IpAddr) -> bool) — implement using stable, non-unsafestd::net methods plus manual octet/segment masking where a needed range has no stable helper method on the pinned toolchain (verify against rust-toolchain.toml = 1.97.1 which stable Ipv4Addr/Ipv6Addr methods are available; do not assume — check the actual stdlib docs for that version before relying on e.g. is_unique_local()/is_unicast_link_local() for IPv6, which stabilized relatively recently). At minimum, block:
IPv4: loopback (127.0.0.0/8), RFC1918 private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16 — this alone covers the cloud-metadata address 169.254.169.254 without hardcoding it), unspecified (0.0.0.0/8), broadcast, multicast.
IPv4-mapped IPv6 addresses (::ffff:0:0/96) MUST be unwrapped to their embedded IPv4 address and re-checked against the IPv4 rules above — an attacker supplying http://[::ffff:127.0.0.1]/ must not bypass the IPv4 loopback check.
crates/nuncio-filter/src/webhook.rs:29-98 (WebhookDispatcher) — add a DNS-resolution SSRF check immediately before the actual HTTP POST (webhook.rs:84-95), using tokio::net::lookup_host (already available: tokio workspace dep has the "full" feature, Cargo.toml:37) on the URL's (host, port). Resolve, then reject the dispatch with WebhookError::SecurityViolation if any resolved address matches is_blocked_webhook_target (reuse the function from validator.rs, making it pub(crate) or moving it somewhere both modules can reach — validator.rs is a reasonable home since webhook.rs already imports from crate::validator, webhook.rs:4).
crates/nuncio-filter/src/webhook.rs:121-144 (#[cfg(test)] mod tests) — the existing test_blocked_private_ip_webhook test (webhook.rs:125-143) must still pass unchanged; add the new tests per Acceptance Criteria alongside it.
Implementation steps
Write is_blocked_webhook_target(ip: IpAddr) -> bool in validator.rs covering the ranges listed above, with the IPv4-mapped-IPv6 unwrap handled first.
Rewrite the CallWebhook arm of pass6_action_security to parse the URL, extract the host, and — if it's a literal IP — call is_blocked_webhook_target. Preserve the existing http:///https:// scheme check (validator.rs:272-276) unchanged.
Add the dispatch-time DNS-resolution check in WebhookDispatcher::dispatch_with_options, after the existing pass6_action_security call (webhook.rs:60-61) and before the HTTP POST (webhook.rs:84).
Document the residual DNS-rebind race explicitly in a code comment on the new dispatch-time check: resolving the hostname and checking the IP, then having reqwest independently re-resolve and connect moments later, leaves a narrow TOCTOU window where a malicious DNS server could return a safe IP to our check and a private IP to reqwest's connect. Note this as a known limitation rather than silently claiming full protection; closing it completely would require a custom resolver/connector pinning the exact checked IP for the actual request, which is out of scope for this story's size (S/M) — call this out in the PR description as a candidate follow-up if the PO wants it hardened further.
Add tests per Acceptance Criteria.
Acceptance criteria
is_blocked_webhook_target (or equivalently-named function) rejects, at minimum: 127.0.0.1, 10.1.2.3, 172.16.0.1, 172.31.255.255 (in-range) but allows 172.32.0.1 (just outside RFC1918's 172.16.0.0/12, a real bypass case for a naive prefix check — proves the CIDR math is correct, not approximate), 192.168.1.1, 169.254.169.254, 169.254.0.1 (any address in the /16, not just the one hardcoded metadata IP), 0.0.0.0, ::1, fc00::1, fe80::1, and ::ffff:127.0.0.1 (IPv4-mapped bypass attempt).
is_blocked_webhook_target allows ordinary public IPs (e.g. 93.184.216.34, 2606:2800:220:1:248:1893:25c8:1946 — example.com's addresses, or any other clearly-public literal) — a false-positive-blocking regression is also a bug.
pass6_action_security rejects CALL WEBHOOK actions whose URL host is a blocked literal IP (replacing the old substring test — update/extend test_pass6_action_security_validation, validator.rs:349-359, plus add cases for the RFC1918/IPv6/IPv4-mapped bypasses above that the old substring check missed).
WebhookDispatcher::dispatch/dispatch_with_options rejects a webhook whose hostname resolves (via a test-injectable or wiremock-free deterministic method — since real DNS resolution of a fabricated hostname can't be mocked with wiremock, prefer testing the resolved-IP-checking logic as a unit test against literal IP inputs, and/or use a hosts-file-independent technique such as directly unit-testing the extracted resolve+check helper function with synthetic IpAddr values rather than performing a live DNS lookup in the test) to a private/loopback address. Live network calls (including live DNS resolution in tests) are forbidden — structure the new dispatch-time check so its IP-blocking logic is unit-testable without performing a real lookup (e.g. factor "resolve host to IPs" and "reject if any IP is blocked" into separate, separately-testable pieces).
The existing test_blocked_private_ip_webhook test (webhook.rs:125-143) still passes unchanged.
cargo verify green (fmt + clippy -D warnings all-targets + test-all), 0 ignored. No live network calls, no live DNS resolution, in any test.
Definition of done & process
Follow docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in .rs/.proto); mock-only tests. Branch story/m6-webhook-ssrf off dev; PR to dev with the gate output pasted as evidence; the PO reviews before the issue is closed.
Dependencies / notes
Not a [PROTO] story. No new crate dependency required — use reqwest::Url (already a transitive re-export via the existing reqwest dependency) for URL/host parsing and std::net::{IpAddr, Ipv4Addr, Ipv6Addr} for the range checks; do not add ipnetwork/cidr/url as new direct dependencies unless a genuine need arises during implementation (if so, justify it in the PR). No file overlap with other M6 stories — safe to run fully in parallel. Full DNS-rebind pinning (custom resolver) is an explicitly out-of-scope stretch goal per the Implementation Steps note above; file a follow-up issue if the PO wants it. Size: M.
## Context
Story for milestone M6. **Read [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md) first** — it defines the process, gates, and patterns; this issue defines only the specific work. `crates/nuncio-filter/src/webhook.rs` (`WebhookDispatcher`) dispatches the `CALL WEBHOOK` filter action as an outbound HTTP POST. Its "SSRF defense" is implemented in `crates/nuncio-filter/src/validator.rs::NsqlValidator::pass6_action_security` (`validator.rs:271-287`). Verified in the tree — the entire check is a case-insensitive **substring match on the raw URL string**:
```rust
if opts.block_private_webhooks {
let url_lower = url.to_lowercase();
if url_lower.contains("127.0.0.1")
|| url_lower.contains("localhost")
|| url_lower.contains("169.254.169.254")
|| url_lower.contains("0.0.0.0")
{
return Err(ValidationError::ActionSecurityViolation(...));
}
}
```
This is trivially bypassable: any RFC1918 address (`10.x.x.x`, `172.16-31.x.x`, `192.168.x.x`), any other `169.254.0.0/16` link-local address (the substring only catches the one literal cloud-metadata IP, not the whole `/16`), decimal/octal/hex IP-literal encodings (e.g. `http://2130706433/` = `127.0.0.1`), `http://127.1/`, any IPv6 loopback/ULA/link-local address (`http://[::1]/`, `http://[fc00::1]/`, `http://[fe80::1]/`), and any DNS name that merely *resolves* to a private/loopback IP (DNS rebinding) all pass this check untouched. The `ValidationOptions::block_private_webhooks` doc comment (`validator.rs:36`) even claims "Pass 6 pre-flight **DNS** IP blacklisting for webhooks" — the DNS resolution it describes does not exist in the implementation (reconciles the reported webhook-SSRF substring-blocklist defect).
## Goal
Replace the substring blocklist with real IP-literal parsing + CIDR-range checks covering RFC1918, loopback, link-local (which subsumes the cloud-metadata address), IPv6 loopback/ULA/link-local, and unspecified/multicast/broadcast addresses — plus a DNS-resolution step at dispatch time so a hostname that resolves to any of those ranges is also rejected, closing the gap the existing doc comment already claims to close but doesn't.
## Files to touch / patterns to mirror
- `crates/nuncio-filter/src/validator.rs:271-287` (`pass6_action_security`, `CallWebhook` arm) — replace the substring check with real parsing:
1. Parse `url` with `reqwest::Url::parse(url)` (no new dependency needed — `nuncio-filter` already depends on `reqwest`, `Cargo.toml:26`, and `reqwest::Url` is a re-export of the `url` crate's `Url` type).
2. Extract the host via `.host_str()`. If it parses as a literal IP address (`host.parse::<std::net::IpAddr>()` succeeds), run it through the new blocked-range predicate (below) and reject if blocked.
3. If the host is a name (not a literal IP), this sync Pass-6 check cannot resolve DNS (it's not async) — leave hostname-based SSRF-via-DNS-rebind to the dispatch-time check described below; Pass 6 at rule-creation time only needs to catch literal-IP bypasses (which is what the current substring check was trying, and failing, to do).
- `crates/nuncio-filter/src/validator.rs` (new private function, e.g. `fn is_blocked_webhook_target(ip: std::net::IpAddr) -> bool`) — implement using **stable, non-`unsafe`** `std::net` methods plus manual octet/segment masking where a needed range has no stable helper method on the pinned toolchain (verify against `rust-toolchain.toml` = 1.97.1 which stable `Ipv4Addr`/`Ipv6Addr` methods are available; do not assume — check the actual stdlib docs for that version before relying on e.g. `is_unique_local()`/`is_unicast_link_local()` for IPv6, which stabilized relatively recently). At minimum, block:
- IPv4: loopback (`127.0.0.0/8`), RFC1918 private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), link-local (`169.254.0.0/16` — this alone covers the cloud-metadata address `169.254.169.254` without hardcoding it), unspecified (`0.0.0.0/8`), broadcast, multicast.
- IPv6: loopback (`::1`), unique-local (`fc00::/7`), link-local (`fe80::/10`), unspecified (`::`), multicast.
- IPv4-mapped IPv6 addresses (`::ffff:0:0/96`) MUST be unwrapped to their embedded IPv4 address and re-checked against the IPv4 rules above — an attacker supplying `http://[::ffff:127.0.0.1]/` must not bypass the IPv4 loopback check.
- `crates/nuncio-filter/src/webhook.rs:29-98` (`WebhookDispatcher`) — add a DNS-resolution SSRF check immediately before the actual HTTP POST (`webhook.rs:84-95`), using `tokio::net::lookup_host` (already available: `tokio` workspace dep has the `"full"` feature, `Cargo.toml:37`) on the URL's `(host, port)`. Resolve, then reject the dispatch with `WebhookError::SecurityViolation` if **any** resolved address matches `is_blocked_webhook_target` (reuse the function from `validator.rs`, making it `pub(crate)` or moving it somewhere both modules can reach — `validator.rs` is a reasonable home since `webhook.rs` already imports from `crate::validator`, `webhook.rs:4`).
- `crates/nuncio-filter/src/webhook.rs:121-144` (`#[cfg(test)] mod tests`) — the existing `test_blocked_private_ip_webhook` test (`webhook.rs:125-143`) must still pass unchanged; add the new tests per Acceptance Criteria alongside it.
## Implementation steps
1. Write `is_blocked_webhook_target(ip: IpAddr) -> bool` in `validator.rs` covering the ranges listed above, with the IPv4-mapped-IPv6 unwrap handled first.
2. Rewrite the `CallWebhook` arm of `pass6_action_security` to parse the URL, extract the host, and — if it's a literal IP — call `is_blocked_webhook_target`. Preserve the existing `http://`/`https://` scheme check (`validator.rs:272-276`) unchanged.
3. Add the dispatch-time DNS-resolution check in `WebhookDispatcher::dispatch_with_options`, after the existing `pass6_action_security` call (`webhook.rs:60-61`) and before the HTTP POST (`webhook.rs:84`).
4. Document the residual DNS-rebind race explicitly in a code comment on the new dispatch-time check: resolving the hostname and checking the IP, then having `reqwest` independently re-resolve and connect moments later, leaves a narrow TOCTOU window where a malicious DNS server could return a safe IP to our check and a private IP to `reqwest`'s connect. Note this as a known limitation rather than silently claiming full protection; closing it completely would require a custom resolver/connector pinning the exact checked IP for the actual request, which is out of scope for this story's size (S/M) — call this out in the PR description as a candidate follow-up if the PO wants it hardened further.
5. Add tests per Acceptance Criteria.
## Acceptance criteria
- [ ] `is_blocked_webhook_target` (or equivalently-named function) rejects, at minimum: `127.0.0.1`, `10.1.2.3`, `172.16.0.1`, `172.31.255.255` (in-range) but allows `172.32.0.1` (just outside RFC1918's `172.16.0.0/12`, a real bypass case for a naive prefix check — proves the CIDR math is correct, not approximate), `192.168.1.1`, `169.254.169.254`, `169.254.0.1` (any address in the `/16`, not just the one hardcoded metadata IP), `0.0.0.0`, `::1`, `fc00::1`, `fe80::1`, and `::ffff:127.0.0.1` (IPv4-mapped bypass attempt).
- [ ] `is_blocked_webhook_target` allows ordinary public IPs (e.g. `93.184.216.34`, `2606:2800:220:1:248:1893:25c8:1946` — example.com's addresses, or any other clearly-public literal) — a false-positive-blocking regression is also a bug.
- [ ] `pass6_action_security` rejects `CALL WEBHOOK` actions whose URL host is a blocked literal IP (replacing the old substring test — update/extend `test_pass6_action_security_validation`, `validator.rs:349-359`, plus add cases for the RFC1918/IPv6/IPv4-mapped bypasses above that the old substring check missed).
- [ ] `WebhookDispatcher::dispatch`/`dispatch_with_options` rejects a webhook whose hostname resolves (via a test-injectable or wiremock-free deterministic method — since real DNS resolution of a fabricated hostname can't be mocked with `wiremock`, prefer testing the resolved-IP-checking logic as a unit test against literal IP inputs, and/or use a hosts-file-independent technique such as directly unit-testing the extracted resolve+check helper function with synthetic `IpAddr` values rather than performing a live DNS lookup in the test) to a private/loopback address. Live network calls (including live DNS resolution in tests) are forbidden — structure the new dispatch-time check so its IP-blocking logic is unit-testable without performing a real lookup (e.g. factor "resolve host to IPs" and "reject if any IP is blocked" into separate, separately-testable pieces).
- [ ] The existing `test_blocked_private_ip_webhook` test (`webhook.rs:125-143`) still passes unchanged.
- [ ] `cargo verify` green (fmt + clippy `-D warnings` all-targets + test-all), 0 ignored. No live network calls, no live DNS resolution, in any test.
## Definition of done & process
Follow [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md): no fabricated data; comment convention (intent-only, no story/GH/phase refs in `.rs`/`.proto`); mock-only tests. Branch `story/m6-webhook-ssrf` off `dev`; PR to `dev` with the gate output pasted as evidence; the PO reviews before the issue is closed.
## Dependencies / notes
Not a [PROTO] story. No new crate dependency required — use `reqwest::Url` (already a transitive re-export via the existing `reqwest` dependency) for URL/host parsing and `std::net::{IpAddr, Ipv4Addr, Ipv6Addr}` for the range checks; do not add `ipnetwork`/`cidr`/`url` as new direct dependencies unless a genuine need arises during implementation (if so, justify it in the PR). No file overlap with other M6 stories — safe to run fully in parallel. Full DNS-rebind pinning (custom resolver) is an explicitly out-of-scope stretch goal per the Implementation Steps note above; file a follow-up issue if the PO wants it. Size: M.
Delivered via PR #243 (squash-merged to dev). Replaced the substring SSRF blocklist with is_blocked_webhook_target (exact CIDR: RFC1918/loopback/link-local incl. metadata IP/unspecified/broadcast/multicast for IPv4; loopback/ULA fc00::/7/link-local fe80::/10/unspecified/multicast for IPv6; IPv4-mapped unwrapped first) in pass6 + a dispatch-time DNS-resolution check. Verified: 172.32.0.1 allowed (exact /12 boundary), ::ffff:127.0.0.1 blocked. Residual DNS-rebind TOCTOU documented as a scoped follow-up. NOTE: the WebhookDispatcher this hardens is currently unreachable from the daemon (outbox worker doesn't execute remote mutations yet — see audit follow-up); this makes it safe for when it is wired, and pass6 URL validation is reachable now at rule creation. Reconciles the webhook-SSRF substring defect.
Delivered via PR #243 (squash-merged to dev). Replaced the substring SSRF blocklist with is_blocked_webhook_target (exact CIDR: RFC1918/loopback/link-local incl. metadata IP/unspecified/broadcast/multicast for IPv4; loopback/ULA fc00::/7/link-local fe80::/10/unspecified/multicast for IPv6; IPv4-mapped unwrapped first) in pass6 + a dispatch-time DNS-resolution check. Verified: 172.32.0.1 allowed (exact /12 boundary), ::ffff:127.0.0.1 blocked. Residual DNS-rebind TOCTOU documented as a scoped follow-up. NOTE: the WebhookDispatcher this hardens is currently unreachable from the daemon (outbox worker doesn't execute remote mutations yet — see audit follow-up); this makes it safe for when it is wired, and pass6 URL validation is reachable now at rule creation. Reconciles the webhook-SSRF substring defect.
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.
Context
Story for milestone M6. Read
docs/STORY-WORKFLOW.mdfirst — it defines the process, gates, and patterns; this issue defines only the specific work.crates/nuncio-filter/src/webhook.rs(WebhookDispatcher) dispatches theCALL WEBHOOKfilter action as an outbound HTTP POST. Its "SSRF defense" is implemented incrates/nuncio-filter/src/validator.rs::NsqlValidator::pass6_action_security(validator.rs:271-287). Verified in the tree — the entire check is a case-insensitive substring match on the raw URL string:This is trivially bypassable: any RFC1918 address (
10.x.x.x,172.16-31.x.x,192.168.x.x), any other169.254.0.0/16link-local address (the substring only catches the one literal cloud-metadata IP, not the whole/16), decimal/octal/hex IP-literal encodings (e.g.http://2130706433/=127.0.0.1),http://127.1/, any IPv6 loopback/ULA/link-local address (http://[::1]/,http://[fc00::1]/,http://[fe80::1]/), and any DNS name that merely resolves to a private/loopback IP (DNS rebinding) all pass this check untouched. TheValidationOptions::block_private_webhooksdoc comment (validator.rs:36) even claims "Pass 6 pre-flight DNS IP blacklisting for webhooks" — the DNS resolution it describes does not exist in the implementation (reconciles the reported webhook-SSRF substring-blocklist defect).Goal
Replace the substring blocklist with real IP-literal parsing + CIDR-range checks covering RFC1918, loopback, link-local (which subsumes the cloud-metadata address), IPv6 loopback/ULA/link-local, and unspecified/multicast/broadcast addresses — plus a DNS-resolution step at dispatch time so a hostname that resolves to any of those ranges is also rejected, closing the gap the existing doc comment already claims to close but doesn't.
Files to touch / patterns to mirror
crates/nuncio-filter/src/validator.rs:271-287(pass6_action_security,CallWebhookarm) — replace the substring check with real parsing:urlwithreqwest::Url::parse(url)(no new dependency needed —nuncio-filteralready depends onreqwest,Cargo.toml:26, andreqwest::Urlis a re-export of theurlcrate'sUrltype)..host_str(). If it parses as a literal IP address (host.parse::<std::net::IpAddr>()succeeds), run it through the new blocked-range predicate (below) and reject if blocked.crates/nuncio-filter/src/validator.rs(new private function, e.g.fn is_blocked_webhook_target(ip: std::net::IpAddr) -> bool) — implement using stable, non-unsafestd::netmethods plus manual octet/segment masking where a needed range has no stable helper method on the pinned toolchain (verify againstrust-toolchain.toml= 1.97.1 which stableIpv4Addr/Ipv6Addrmethods are available; do not assume — check the actual stdlib docs for that version before relying on e.g.is_unique_local()/is_unicast_link_local()for IPv6, which stabilized relatively recently). At minimum, block:127.0.0.0/8), RFC1918 private (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16), link-local (169.254.0.0/16— this alone covers the cloud-metadata address169.254.169.254without hardcoding it), unspecified (0.0.0.0/8), broadcast, multicast.::1), unique-local (fc00::/7), link-local (fe80::/10), unspecified (::), multicast.::ffff:0:0/96) MUST be unwrapped to their embedded IPv4 address and re-checked against the IPv4 rules above — an attacker supplyinghttp://[::ffff:127.0.0.1]/must not bypass the IPv4 loopback check.crates/nuncio-filter/src/webhook.rs:29-98(WebhookDispatcher) — add a DNS-resolution SSRF check immediately before the actual HTTP POST (webhook.rs:84-95), usingtokio::net::lookup_host(already available:tokioworkspace dep has the"full"feature,Cargo.toml:37) on the URL's(host, port). Resolve, then reject the dispatch withWebhookError::SecurityViolationif any resolved address matchesis_blocked_webhook_target(reuse the function fromvalidator.rs, making itpub(crate)or moving it somewhere both modules can reach —validator.rsis a reasonable home sincewebhook.rsalready imports fromcrate::validator,webhook.rs:4).crates/nuncio-filter/src/webhook.rs:121-144(#[cfg(test)] mod tests) — the existingtest_blocked_private_ip_webhooktest (webhook.rs:125-143) must still pass unchanged; add the new tests per Acceptance Criteria alongside it.Implementation steps
is_blocked_webhook_target(ip: IpAddr) -> boolinvalidator.rscovering the ranges listed above, with the IPv4-mapped-IPv6 unwrap handled first.CallWebhookarm ofpass6_action_securityto parse the URL, extract the host, and — if it's a literal IP — callis_blocked_webhook_target. Preserve the existinghttp:///https://scheme check (validator.rs:272-276) unchanged.WebhookDispatcher::dispatch_with_options, after the existingpass6_action_securitycall (webhook.rs:60-61) and before the HTTP POST (webhook.rs:84).reqwestindependently re-resolve and connect moments later, leaves a narrow TOCTOU window where a malicious DNS server could return a safe IP to our check and a private IP toreqwest's connect. Note this as a known limitation rather than silently claiming full protection; closing it completely would require a custom resolver/connector pinning the exact checked IP for the actual request, which is out of scope for this story's size (S/M) — call this out in the PR description as a candidate follow-up if the PO wants it hardened further.Acceptance criteria
is_blocked_webhook_target(or equivalently-named function) rejects, at minimum:127.0.0.1,10.1.2.3,172.16.0.1,172.31.255.255(in-range) but allows172.32.0.1(just outside RFC1918's172.16.0.0/12, a real bypass case for a naive prefix check — proves the CIDR math is correct, not approximate),192.168.1.1,169.254.169.254,169.254.0.1(any address in the/16, not just the one hardcoded metadata IP),0.0.0.0,::1,fc00::1,fe80::1, and::ffff:127.0.0.1(IPv4-mapped bypass attempt).is_blocked_webhook_targetallows ordinary public IPs (e.g.93.184.216.34,2606:2800:220:1:248:1893:25c8:1946— example.com's addresses, or any other clearly-public literal) — a false-positive-blocking regression is also a bug.pass6_action_securityrejectsCALL WEBHOOKactions whose URL host is a blocked literal IP (replacing the old substring test — update/extendtest_pass6_action_security_validation,validator.rs:349-359, plus add cases for the RFC1918/IPv6/IPv4-mapped bypasses above that the old substring check missed).WebhookDispatcher::dispatch/dispatch_with_optionsrejects a webhook whose hostname resolves (via a test-injectable or wiremock-free deterministic method — since real DNS resolution of a fabricated hostname can't be mocked withwiremock, prefer testing the resolved-IP-checking logic as a unit test against literal IP inputs, and/or use a hosts-file-independent technique such as directly unit-testing the extracted resolve+check helper function with syntheticIpAddrvalues rather than performing a live DNS lookup in the test) to a private/loopback address. Live network calls (including live DNS resolution in tests) are forbidden — structure the new dispatch-time check so its IP-blocking logic is unit-testable without performing a real lookup (e.g. factor "resolve host to IPs" and "reject if any IP is blocked" into separate, separately-testable pieces).test_blocked_private_ip_webhooktest (webhook.rs:125-143) still passes unchanged.cargo verifygreen (fmt + clippy-D warningsall-targets + test-all), 0 ignored. No live network calls, no live DNS resolution, in any test.Definition of done & process
Follow
docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in.rs/.proto); mock-only tests. Branchstory/m6-webhook-ssrfoffdev; PR todevwith the gate output pasted as evidence; the PO reviews before the issue is closed.Dependencies / notes
Not a [PROTO] story. No new crate dependency required — use
reqwest::Url(already a transitive re-export via the existingreqwestdependency) for URL/host parsing andstd::net::{IpAddr, Ipv4Addr, Ipv6Addr}for the range checks; do not addipnetwork/cidr/urlas new direct dependencies unless a genuine need arises during implementation (if so, justify it in the PR). No file overlap with other M6 stories — safe to run fully in parallel. Full DNS-rebind pinning (custom resolver) is an explicitly out-of-scope stretch goal per the Implementation Steps note above; file a follow-up issue if the PO wants it. Size: M.Delivered via PR #243 (squash-merged to dev). Replaced the substring SSRF blocklist with is_blocked_webhook_target (exact CIDR: RFC1918/loopback/link-local incl. metadata IP/unspecified/broadcast/multicast for IPv4; loopback/ULA fc00::/7/link-local fe80::/10/unspecified/multicast for IPv6; IPv4-mapped unwrapped first) in pass6 + a dispatch-time DNS-resolution check. Verified: 172.32.0.1 allowed (exact /12 boundary), ::ffff:127.0.0.1 blocked. Residual DNS-rebind TOCTOU documented as a scoped follow-up. NOTE: the WebhookDispatcher this hardens is currently unreachable from the daemon (outbox worker doesn't execute remote mutations yet — see audit follow-up); this makes it safe for when it is wired, and pass6 URL validation is reachable now at rule creation. Reconciles the webhook-SSRF substring defect.