Skip to content

fix(security): pin outbound fetch connections to the validated IP (F-11) - #275

Merged
anilguleroglu merged 2 commits into
mainfrom
fix/p1-ssrf-connection-pinning
Sep 6, 2026
Merged

fix(security): pin outbound fetch connections to the validated IP (F-11)#275
anilguleroglu merged 2 commits into
mainfrom
fix/p1-ssrf-connection-pinning

Conversation

@anilguleroglu

Copy link
Copy Markdown
Collaborator

Summary

P1 finding from the 2026-09-05 finance-institution assessment.

assertPublicUrl resolved DNS once to decide a hostname was public, then handed the hostname (not the resolved address) to fetch, which resolves it again, independently, at connect time. An attacker controlling DNS for the target can answer publicly for the policy check and privately for the actual connection — classic TOCTOU / DNS rebinding — and nothing between those two lookups closed the gap.

safeFetch now:

  1. Does a second, connection-time DNS resolution immediately before each fetch call (not reused from the 30s policy cache — freshness is the whole point).
  2. Re-validates that result for privacy too, in case the two lookups already disagree.
  3. Pins the actual socket to that exact address via an undici.Agent with a custom connector, so whatever fetch's own internal resolution would have produced is never consulted — the connection goes to the address this guard just checked, not to whatever DNS answers a moment later.

Applies per redirect hop, since safeFetch already re-validates every hop the same way.

undici was already an installed transitive dependency (several other packages pull it in) — promoted to a direct dependency since the app now imports it explicitly for this.

No pin is attempted for a literal-IP URL (nothing to resolve) or when the private-network guard itself was bypassed (allowlisted host, allowPrivate, or blocking disabled globally) — those paths weren't making an "is this the address I checked" promise to begin with.

Test plan

  • New tests: a dispatcher is attached for a resolved hostname, absent for a literal IP, absent when the guard is bypassed
  • The core regression test: mocked DNS returns a public address on the first (policy) lookup and a private address on the second (connection-time) lookup — safeFetch now rejects it (proven to fail against the pre-fix code: reverted the re-check, confirmed red, restored)
  • A companion test confirms a legitimately different-but-still-public second answer still succeeds (not just "always reject a second lookup")
  • All 20 pre-existing outbound-fetch.test.ts + guardrail-fix2-outbound-redirect-ssrf.test.ts tests still pass unmodified
  • npx tsc --noEmit clean
  • npx eslint clean (0 errors, 0 warnings on changed files)
  • Full vitest run: 5051 passed, 0 failed, 5 skipped
  • Manually verified the pinning mechanism against a real local HTTP server before wiring it into the guard (a hostname that does not resolve to it in real DNS, pinned via a custom connector, actually reached it)

🤖 Generated with Claude Code

https://claude.ai/code/session_01KQq6TnVNHRNU6Wz1eQzPpD

assertPublicUrl resolved DNS once to decide a hostname was public, then
handed the HOSTNAME (not the resolved address) to fetch, which
resolves it AGAIN, independently, at connect time. An attacker
controlling DNS for the target can answer publicly for the policy
check and privately for the actual connection -- classic TOCTOU / DNS
rebinding -- and nothing between those two lookups closed the gap.

safeFetch now does a second, connection-time DNS resolution
immediately before each fetch, re-validates ITS result for privacy too
(in case the two lookups already disagree), and pins the actual socket
to that exact address via an undici Agent with a custom connector --
so whatever fetch's own internal resolution would have produced is
never consulted; the connection goes to the address this guard just
checked, not to whatever DNS answers a moment later. Applies per hop,
since safeFetch already re-validates every redirect the same way.

undici was already an installed transitive dependency (multiple other
packages pull it in); promoted to a direct dependency since the app
now imports it directly for this.

No pin is attempted for a literal-IP URL (nothing to resolve) or when
the private-network guard itself was bypassed (allowlisted host,
allowPrivate, or blocking disabled) -- those paths were not making an
"is this the address I checked" promise to begin with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQq6TnVNHRNU6Wz1eQzPpD
@anilguleroglu
anilguleroglu requested a review from a team September 6, 2026 13:15
…sses

Three things the pre-merge review of this PR turned up.

1. SOCKET LEAK. `pinnedDispatcher()` built a new undici `Agent` per fetch
   (and per redirect hop) and never disposed of it. Undici's default
   keep-alive holds the socket open for a reuse that, for a per-request
   dispatcher, never comes — one leaked file descriptor per outbound
   call, which a crawl run reaches the process FD limit with. The agent
   now uses minimal keep-alive, and every dispatcher whose response
   nobody will read (each redirect hop, the error path, the
   redirect-cap throw) is destroyed explicitly; only the one whose
   response is handed back to the caller outlives the call.

2. LOST DUAL-STACK FAILOVER. The pin used `records[0]` only, so a host
   whose first DNS answer is an AAAA this container cannot route went
   from "works" (fetch tries the next address) to "always fails". All
   resolved addresses are now handed to the connector — and ALL of them
   are privacy-checked, not just the first, so a second private record
   cannot be the one it connects to.

3. UNVERIFIED CONNECTOR CONTRACT. Every existing test mocks `fetch`, so
   nothing exercised the custom `lookup` callback the pin depends on —
   if its shape were wrong for this Node/undici, every guarded outbound
   call in the product would fail at connect time and no test would
   notice. Adds outbound-fetch-pinning-live.test.ts: a real HTTP server
   on loopback, reached through the real dispatcher via a hostname that
   can never resolve (RFC 2606 .invalid), plus a failover case proving
   the multi-address list actually falls through to the second entry.

Test plan: outbound-fetch.test.ts 22/22 unchanged and passing; 2 new
live-connection tests; full suite 5053 passed, 5 skipped, 0 failed;
tsc + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQq6TnVNHRNU6Wz1eQzPpD
Comment on lines +416 to +421
response = await fetch(url, {
...init,
signal: controller.signal,
redirect: 'manual',
...(dispatcher ? { dispatcher } : {}),
} as RequestInit);
@anilguleroglu
anilguleroglu merged commit 8ced49d into main Sep 6, 2026
4 of 5 checks passed
@anilguleroglu
anilguleroglu deleted the fix/p1-ssrf-connection-pinning branch September 6, 2026 21:40
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.

2 participants