fix: prevent DNS-rebinding TOCTOU in safeProxyFetch by pinning resolved IPs - #1732
Conversation
1d380ef to
5cccad2
Compare
…ed IPs Previously assertSafeProxyTarget resolved the target hostname and validated the IPs, but safeProxyFetch then passed the raw URL to node-fetch, which performed its own DNS lookup. In the window between the two resolutions an attacker who controls the domain's TTL could flip the record to 169.254.169.254 (cloud-metadata), causing fetch() to connect to the instance- metadata service even though the block-list check passed. Fix: assertSafeProxyTarget now returns the validated IP addresses. On each hop of safeProxyFetch we call createPinnedAgent() to build an http/https.Agent whose lookup hook unconditionally returns the pre-validated IP. node-fetch uses that hook instead of the OS resolver, closing the TOCTOU window entirely. Refactoring: isBlockedProxyAddress, assertSafeProxyTarget, and the new createPinnedAgent are extracted to server/src/proxy-security.ts so they can be unit-tested in isolation. A vitest suite is added to the server package (the first unit tests for this package) with 26 cases covering the block-list, DNS validation, IP-pinning, and the TOCTOU guarantee itself.
5cccad2 to
9140789
Compare
There was a problem hiding this comment.
Pull request overview
Pins validated DNS addresses to prevent DNS-rebinding TOCTOU attacks in the /fetch proxy.
Changes:
- Extracts SSRF validation and adds IP-pinned agents.
- Updates redirect handling to pin each validated hop.
- Adds server security tests and Vitest configuration.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
server/src/proxy-security.ts |
Adds DNS validation and pinned-agent helpers. |
server/src/index.ts |
Uses validated IPs for proxy requests. |
server/src/__tests__/proxy-security.test.ts |
Tests SSRF security helpers. |
server/vitest.config.ts |
Configures server unit tests. |
server/package.json |
Adds Vitest scripts and dependencies. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Addresses the round-1 Copilot review.
- createPinnedAgent: Node's net.connect runs with autoSelectFamily on
(Node >= 20), so it calls the lookup hook with `{ all: true }` and
requires an ARRAY of `{ address, family }`. The scalar-only callback
failed every hostname request with ERR_INVALID_IP_ADDRESS, so /fetch
could only reach literal-IP targets. Both callback shapes are now
supported. Reproduced against Node 22/26 before and after.
- Tests: cover the `{ all: true }` shape for IPv4 and IPv6, and add an
end-to-end request through the agent over the production path
(node-fetch -> http.Agent -> net.connect) using an unresolvable
hostname, so the request can only succeed via the pinned IP. Verified
these three fail without the fix. Replaced the `Function`-typed casts
with a typed pinnedLookup() helper.
- CI: run the new server suite (.github/workflows/main.yml) and add a
root `test-server` script, so this regression coverage is enforced.
- package-lock.json: sync the server workspace manifest mirror with the
added @types/node and vitest devDependencies.
- index.ts: drop the `agent as any` cast and its eslint-disable — the
node-fetch RequestInit already types `agent`, and tsc is clean without
the suppression.
- Formatting: prettier-check now passes (it failed on the two new files).
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review — round 1 responses (pushed as
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
server/src/index.ts:894
- Pinning only
validatedAddresses[0]removes Node's normal address-family fallback. For a dual-stack hostname whose first DNS result is unreachable (for example, IPv6 on an IPv4-only network) but a later validated address is reachable,/fetchnow fails even though the target is available. Preserve the TOCTOU protection while passing all validated addresses to the lookup hook so its{ all: true }callback can letnet.connectselect among only those prevalidated results; add a dual-stack fallback test as well.
const agent = createPinnedAgent(currentUrl.protocol, validatedAddresses[0]);
server/src/tests/proxy-security.test.ts:1
- This test directory is inside
server/tsconfig.json'srootDir/include, and that config excludes only*.spec.ts, sonpm run buildemits the suite intoserver/build/__tests__; the server package then publishes it becauseserver/package.jsonincludes the entirebuilddirectory. Keep tests out of production artifacts by excludingsrc/**/__tests__/**(asclient/tsconfig.app.json:31does) or moving the suite outsidesrc(as the CLI tests are kept outsidecli/src).
/**
Addresses the round-2 Copilot review (both suppressed comments). - createPinnedAgent now takes the whole validated address list instead of a single IP, so Node's address-family fallback still works: a dual-stack host whose first address is unreachable (IPv6 on an IPv4-only network) connects via a later one. The set net.connect may choose from is exactly the prevalidated set, so the TOCTOU guarantee is unchanged. Empty input throws ProxyTargetError rather than pinning undefined. - Tests: dual-stack pass-through, the empty-list guard, and an end-to-end request where the first pinned address is a black hole (2001:db8::1) and the connection must complete via the second. - server/tsconfig.json excludes src/**/__tests__/** so the suite is no longer emitted into build/ and published with the package (verified: build/__tests__ is gone). tsconfig.test.json keeps the suite type-checked, and `npm test` runs it before vitest. Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review — round 2 responses (pushed as
|
Copilot review — round 3: clean ✅Re-requested against Summary of the two roundsRound 1 (5 findings, 4 fixed + 1 declined) —
Round 2 (2 suppressed findings, both fixed) —
Net effect on the original vulnerability fix: the DNS-pinning approach is unchanged and still closes the TOCTOU window; what changed is that it now actually works for hostname targets, preserves dual-stack fallback, is enforced by CI, and does not ship tests to npm. |
NOTE
Problem
safeProxyFetchcontains a TOCTOU (time-of-check / time-of-use) race that lets a DNS-rebinding attack reach cloud instance-metadata services (169.254.169.254).Attack flow (before this fix)
evil.example.comwith a very short TTL.evil.example.com./fetchproxy callsassertSafeProxyTarget("evil.example.com")→ DNS resolves to1.2.3.4→ passes block-list check ✅169.254.169.254before the TTL expires.node-fetchresolvesevil.example.comagain (its own lookup) → gets169.254.169.254→ connects to the AWS/GCP/Azure metadata endpoint.The vulnerability exists because
assertSafeProxyTargetandnode-fetcheach perform an independent DNS resolution. The gap between them is the attack window.Solution
assertSafeProxyTargetnow returns the list of validated IP addresses instead of returningvoid. On each hop ofsafeProxyFetch, the first validated IP is passed tocreatePinnedAgent(), which builds anhttp.Agent/https.Agentwhoselookuphook unconditionally returns that IP.node-fetchcalls the hook instead of the OS resolver, so there is never a second DNS lookup — the TOCTOU window is eliminated.Changes
New file:
server/src/proxy-security.tsSecurity helpers extracted from
index.tsso they can be unit-tested in isolation:isBlockedProxyAddress(ip)— unchanged logic, now exportedassertSafeProxyTarget(url)— now returnsstring[](validated IPs)createPinnedAgent(protocol, ip)— new: builds an http/https agent pinned to a specific IPModified:
server/src/index.tsproxy-security.tssafeProxyFetchcallscreatePinnedAgenton every hop using the IPs returned byassertSafeProxyTargetNew file:
server/src/__tests__/proxy-security.test.tsFirst unit-test suite for the server package (previously had zero tests). 26 test cases across 3 suites:
isBlockedProxyAddressassertSafeProxyTargetcreatePinnedAgentNew files:
server/vitest.config.ts,server/package.json(test scripts + vitest devDep)Testing
All 26 new unit tests pass. Existing integration tests in
client/src/__tests__/proxyFetchEndpoint.test.tscontinue to pass unchanged.References