Skip to content

feat(net): SSRF-safe fetch primitive + DNS-pinned rawMcpProbe#598

Merged
bokelley merged 1 commit into
mainfrom
bokelley/ssrf-dns-pinned
Apr 19, 2026
Merged

feat(net): SSRF-safe fetch primitive + DNS-pinned rawMcpProbe#598
bokelley merged 1 commit into
mainfrom
bokelley/ssrf-dns-pinned

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Closes #573. First of three PRs bundling the verifier follow-ups from the RFC 9421 review (#583 items 1+2, #584, #585 remain for PR B/C).

Summary

  • Extract the DNS-pinning + SSRF-validation guard previously local to src/lib/testing/storyboard/probes.ts::fetchProbe into a reusable @adcp/client/net primitive. Future HTTPS fetchers (JWKS auto-refresh, revocation-list polling in signing: JWKS auto-refresh + revocation-list polling for long-running servers #584) consume the same guard.
  • Rewire rawMcpProbe — previously using the node global fetch with no SSRF check — to dispatch through the primitive. Closes the TOCTOU gap where an attacker's authoritative nameserver could serve a public IP to a validator and a private IP to the actual connect.

The primitive

ssrfSafeFetch(url, options) returns a buffered { status, headers, body, pinnedAddress, pinnedFamily } or throws a typed SsrfRefusedError with one of: invalid_url, scheme_not_allowed, non_https_without_opt_in, dns_lookup_failed, dns_empty, always_blocked_address, private_address, body_exceeds_limit.

Guarantees (behavior preserved from existing fetchProbe, now uniformly applied):

  • Scheme gate: https: only; http: under allowPrivateIp dev opt-in. file:/data:/ftp: always refused.
  • DNS pinning: resolves A + AAAA once, validates the full set, pins the first validated address into undici.Agent.connect.lookup. Connect-time DNS is bypassed entirely — no rebind window.
  • IMDS always refused: 169.254.0.0/16 + fe80::/10 refused even under allowPrivateIp.
  • Redirects not followed (redirect: 'manual'). 3xx responses are returned with Location for inspection; callers MUST re-invoke ssrfSafeFetch with the new URL to re-validate — documented on the module contract.
  • Body cap: 64 KiB default, throws body_exceeds_limit if exceeded.
  • Per-request dispatcher: new Agent per call, closed in finally. Two concurrent calls with different pinned IPs don't interfere.

Address classification hardening (from security review)

Addressed two bypasses the reviewer caught:

  • IPv6 zone IDs stripped before classification. fe80::1%eth0 now correctly flags as link-local. Previously a bracketed URL literal http://[fe80::1%eth0]/ under allowPrivateIp: true could reach IMDS.
  • Non-canonical IPv4-mapped IPv6 normalized via net.BlockList instead of regex matching. 0:0:0:0:0:ffff:127.0.0.1 now classifies as loopback (previous regex required ::ffff: prefix).
  • NAT64 (64:ff9b::/96) and 6to4 (2002::/16) wrapper prefixes refused unconditionally when allowPrivateIp is off — defense in depth against caller-edge translation into private targets.
  • URL brackets stripped before DNS lookup and classification. https://[::1]/ now works correctly under allowPrivateIp and is rejected as loopback by default.

Info-disclosure hygiene

SsrfRefusedError.message carries the counterparty-supplied hostname only; the resolved IP lives on .address. A hostname that resolves into the caller's internal network now won't leak topology into compliance reports / log aggregators.

Changes in tree

Test plan

  • npm run typecheck clean
  • npm run format:check clean
  • npm run build clean
  • node --test test/lib/net-ssrf-fetch.test.js — 26/26 pass (all new)
  • node --test test/lib/storyboard-security.test.js — 68/68 pass
  • npm test — 3671/3701 pass. The 12 failures are pre-existing Governance E2E suites broken by v3.0.0-rc.4: migrate to reallocation_threshold + human_review_required (breaking) #576's reallocation_threshold migration (verified they fail identically on clean main: 7 pass / 20 fail on governance-e2e.test.js alone).
  • CI green (in progress)

🤖 Generated with Claude Code

Extract the SSRF-validation + DNS-pinning guard previously local to the
storyboard `fetchProbe` into a reusable `@adcp/client/net` primitive, and
rewire `rawMcpProbe` to dispatch through it so attacker-influenced agent
URLs can't escape validation via naive global `fetch`.

- `ssrfSafeFetch(url, options)` — buffered GET/POST with typed
  `SsrfRefusedError` codes. DNS resolved once, validated across the full
  address set, pinned into the undici `Agent.connect.lookup`, and torn
  down per-request so connection reuse can't carry a rebind.
- Address classification via `net.BlockList` (canonicalizes IPv4-mapped
  IPv6 natively) plus defense-in-depth on wrapper prefixes:
  NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`). Zone IDs (`%eth0`) and
  URL brackets (`[::1]`) are stripped before classification.
- IMDS (`169.254.169.254`, `fe80::/10`) refused even under the
  `allowPrivateIp` dev opt-in.
- Error messages carry hostname only; the resolved IP stays on
  `SsrfRefusedError.address` so counterparty-supplied URLs can't leak
  internal-network topology into compliance reports.
- `rawMcpProbe` now accepts `allowPrivateIp` and refuses private /
  non-HTTPS targets by default; the storyboard runner threads
  `options.allow_http` through for dev loops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 0884b25 into main Apr 19, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rawMcpProbe should dispatch through DNS-pinned undici Agent (close TOCTOU gap)

1 participant