Repository navigation
Close the DNS-rebinding gap by pinning outbound sockets to the validated address - #8
Conversation
Validating a hostname and then handing that hostname to `fetch` is a time-of-check/time-of-use bug. `fetch` resolves the name a second time, so an attacker serving a zero-TTL record answers the guard's lookup with a public address and the client's lookup with 169.254.169.254. Every check the guard performed described an address the request never used. This was recorded as a known residual risk rather than fixed; it is now fixed. Outbound requests resolve once through a controlled resolver, validate every address returned, and open the connection to the validated address via a per-request `lookup` on node:http/node:https. Node's `fetch` cannot express this without an `undici` dependency the project does not carry. The hostname is deliberately not pinned: `options.host` stays the name, so TLS SNI, certificate validation and the Host header are unchanged and only the TCP peer is substituted. Rewriting the URL to the IP — the obvious shortcut — would trade an SSRF hole for a transport-security hole. Three bypasses in the literal guard are closed alongside it. The address check read exactly one spelling of an address, a dotted quad, so `127.1`, `0177.0.0.1`, `0x7f000001` and `2130706433` fell through as ordinary hostnames despite reaching loopback through getaddrinfo. So did `::ffff:7f00:1` — the IPv4-mapped form the WHATWG URL parser actually produces, meaning the old mapped-address check could not fire on a parsed URL. Addresses are now parsed to bytes and classified as bytes in every notation a resolver accepts; IPv6 is allow-listed to global unicast rather than block-listed; and 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24 and Azure's globally routable 168.63.129.16 are refused. Bodies are read under a byte cap applied to the encoded and decoded streams alike, since a compression bomb exhausts memory before any decoded limit is consulted, and the redirect chain now spends one timeout budget rather than one per hop. No capability status changed and no roadmap phase moved. This closes one phase-2 acceptance criterion and leaves the rest as they were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQ2eShwv2iVM3CQYpjEkxK
Pinning changed multi-address behaviour and the change was not written down. Previously every resolved address was validated and the hostname was handed to `fetch`, which chose an address and could fall back to another if the first refused. Now every address is still validated but exactly one is connected to, with no fallback. That is the deliberate cost of pinning — falling back would mean connecting to an address chosen after the check, which is the hole the pin closes — but it is a real operational difference for a multi-homed host, so it belongs in the runbook's residual-risk list and in ADR-016's consequences rather than only in a pull request description. Documentation only. No code, capability or roadmap change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQ2eShwv2iVM3CQYpjEkxK
Pre-merge review — findingsRead-only review against eight criteria. No blockers. One documentation gap found and closed in Three behaviours below are worth recording. All three exist identically on 1. A POST body survives a cross-origin redirectCredentials are stripped when a redirect crosses origin, but the method and body are not. Verified with a stub transport: This matters most for Related: on a 2.
|
Closes the last open SSRF item, recorded until now as a known residual risk in
docs/operations-runbook.md§4 anddocs/release-truth-audit.md.The vulnerability
safeFetchvalidated a hostname and then handed that hostname tofetch, which performs its own second DNS resolution. Between the two lookups the answer can change:checkResolvedHost()resolvesattacker.example→93.184.216.34→ allowed.fetch('https://attacker.example/')resolves again →169.254.169.254.A zero-TTL record is all an attacker needs, and they only need to control DNS for a name they own. The validated address never reached the socket layer — that was the entire defect.
Three further bypasses found while fixing it
Not previously documented, all in the literal guard:
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.127.1,0177.0.0.1,0x7f000001and2130706433are not matched, contain a dot (or no colon), and fell through toreturn { allowed: true }as ordinary hostnames. Confirmed againstdns.lookup: all four reach127.0.0.1.::ffff:127.0.0.1, butnew URL('http://[::ffff:127.0.0.1]/').hostnamereturns[::ffff:7f00:1]. Since parsed URLs are the only way these reach the guard, the check could not fire.0:0:0:0:0:ffff:169.254.169.254normalizes to::ffff:a9fe:a9feand was likewise allowed.2001:db8::/32, multicast and site-local all passed.Plus missing IPv4 ranges (
192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24) and Azure's168.63.129.16, which is globally routable and so was caught by nothing.The fix
src/lib/net-pinned.ts(new) — the transport.node:http/node:httpswith a per-requestlookupthat ignores the hostname it is handed and returns the one approved address. There is no second lookup to poison because there is no second lookup. Node'sfetchcannot express this: thedispatcheroption that would allow it needs anundicidependency the project does not carry.The hostname is deliberately not pinned.
options.hoststays the name, so TLS SNI, certificate validation and theHostheader are unchanged; only the TCP peer is substituted. Rewriting the URL to the IP — the obvious shortcut — silently disables certificate validation and trades an SSRF hole for a transport-security hole. A post-connect assertion comparessocket.remoteAddressagainst the pin and hangs up on mismatch.src/lib/ip-address.ts(new) — parses to bytes and classifies bytes. Loose IPv4 (inet_atonsemantics: 1–4 parts, decimal/octal/hex) and full IPv6 (::compression, embedded IPv4, zone IDs, RFC 5952 canonicalization). IPv6 is allow-listed to global unicast2000::/3minus carve-outs, rather than block-listed — the space is too large to enumerate what is unsafe. Embedded-IPv4 forms are recursively classified as the IPv4 address they carry.src/lib/net-guard.ts— delegates to the parser; exported signatures unchanged, so both route call sites are untouched. A host that is trying to be an address and failing (999.1.1.1) is now refused rather than demoted to a hostname.src/lib/net-fetch.ts— resolve once → validate every record → pin the first → connect. Repeated per redirect hop, each pinned independently. One timeout budget across the whole chain rather than one per hop, and a byte cap passed to the transport.Bodies are read under a cap applied to the encoded and decoded streams alike — a compression bomb exhausts memory long before any decoded limit is consulted.
allowPrivateHostsdisables validation only; the pin still applies. A request whose destination is unknowable is not made safer by skipping a check.Test coverage
74 tests across four files, +59 net.
tests/net-pinned.test.ts(13, new)pinned.invalid, a name RFC 2606 guarantees never resolves — a response therefore proves no DNS lookup occurred. Host header preservation, TLS SNI andhostoptions, pinned-lookup return values, peer-address mismatch, byte cap, decompressed cap, deadline, connection failure, 3xx not followedtests/ip-address.test.ts(14, new)tests/net-fetch.test.ts(27, was 15)tests/net-guard.test.ts(28, was 15)tests/crawler.integration.test.tsandtests/wordpress.integration.test.tsexercise the new transport end to end against real fixture servers and were not modified.Verification
Run locally against PostgreSQL 16, matching the CI job:
npm run typecheck— cleannpm run lint— cleannpm test— 663 passed / 663, 39 files (previously 576 with 87 skipped for want of a database)npm run build— compiled successfullynpm run db:rehearse— passedprisma migrate deploy+ drift check — no driftScope
No capability status changed.
src/lib/capabilities.tsandsrc/lib/roadmap.tsare untouched, no flag was activated, nothing was deployed, Railway was not modified, and Phase 2 is not marked complete. This satisfies onephase-2acceptance criterion — "SSRF protection resolves DNS and defends against redirects and rebinding" — and leaves the phase's other criteria as they were.Documentation updated to match: the runbook's residual-risk list (renumbered), the release truth audit (recorded as closed-after-Phase-2 rather than edited out of history), and ADR-016.
Remaining risks
maxBytes(5 MiB default), but a large crawl holds that per in-flight request.keep-alivepooling are not used; each request opens its own connection. Correctness over throughput, and reconsidering it means re-deriving the pin per pooled socket.Generated by Claude Code