Skip to content

fix: prevent DNS-rebinding TOCTOU in safeProxyFetch by pinning resolved IPs#1732

Open
manjunathbhaskar wants to merge 1 commit into
modelcontextprotocol:mainfrom
manjunathbhaskar:fix/dns-rebinding-toctou-safeproxyfetch
Open

fix: prevent DNS-rebinding TOCTOU in safeProxyFetch by pinning resolved IPs#1732
manjunathbhaskar wants to merge 1 commit into
modelcontextprotocol:mainfrom
manjunathbhaskar:fix/dns-rebinding-toctou-safeproxyfetch

Conversation

@manjunathbhaskar

Copy link
Copy Markdown

Problem

safeProxyFetch contains 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)

  1. Attacker registers evil.example.com with a very short TTL.
  2. Inspector user connects to an MCP server at evil.example.com.
  3. The /fetch proxy calls assertSafeProxyTarget("evil.example.com") → DNS resolves to 1.2.3.4 → passes block-list check ✅
  4. Attacker flips DNS to 169.254.169.254 before the TTL expires.
  5. node-fetch resolves evil.example.com again (its own lookup) → gets 169.254.169.254 → connects to the AWS/GCP/Azure metadata endpoint.
  6. The metadata response (IAM tokens, credentials) is forwarded back to the attacker.

The vulnerability exists because assertSafeProxyTarget and node-fetch each perform an independent DNS resolution. The gap between them is the attack window.

Solution

assertSafeProxyTarget now returns the list of validated IP addresses instead of returning void. On each hop of safeProxyFetch, the first validated IP is passed to createPinnedAgent(), which builds an http.Agent / https.Agent whose lookup hook unconditionally returns that IP. node-fetch calls the hook instead of the OS resolver, so there is never a second DNS lookup — the TOCTOU window is eliminated.

Before:  assertSafeProxyTarget(url)   ← DNS lookup #1, validated
         fetch(url)                   ← DNS lookup #2, unvalidated ← attack window

After:   validatedIps = await assertSafeProxyTarget(url)  ← DNS lookup #1
         agent = createPinnedAgent(protocol, validatedIps[0])
         fetch(url, { agent })        ← no DNS lookup, uses pinned IP

Changes

New file: server/src/proxy-security.ts

Security helpers extracted from index.ts so they can be unit-tested in isolation:

  • isBlockedProxyAddress(ip) — unchanged logic, now exported
  • assertSafeProxyTarget(url) — now returns string[] (validated IPs)
  • createPinnedAgent(protocol, ip)new: builds an http/https agent pinned to a specific IP

Modified: server/src/index.ts

  • Imports the three helpers from proxy-security.ts
  • Removes the duplicate inline implementations (~85 lines deleted)
  • safeProxyFetch calls createPinnedAgent on every hop using the IPs returned by assertSafeProxyTarget

New file: server/src/__tests__/proxy-security.test.ts

First unit-test suite for the server package (previously had zero tests). 26 test cases across 3 suites:

Suite Cases
isBlockedProxyAddress IPv4 link-local, IPv6 link-local, AWS IPv6 IMDS, IPv4-mapped IPv6 (hex + dotted), safe IPs, non-IP strings
assertSafeProxyTarget safe hostname, blocked hostname, mixed DNS results, DNS failure, literal IPv4/IPv6 hosts, multi-address return
createPinnedAgent correct agent type per protocol, lookup always returns pinned IPv4, lookup always returns pinned IPv6, TOCTOU guarantee (OS resolver is never invoked)

New files: server/vitest.config.ts, server/package.json (test scripts + vitest devDep)

Testing

cd server
npm install
npm test

All 26 new unit tests pass. Existing integration tests in client/src/__tests__/proxyFetchEndpoint.test.ts continue to pass unchanged.

References

@manjunathbhaskar
manjunathbhaskar force-pushed the fix/dns-rebinding-toctou-safeproxyfetch branch from 1d380ef to 5cccad2 Compare July 21, 2026 10:45
…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.
@manjunathbhaskar
manjunathbhaskar force-pushed the fix/dns-rebinding-toctou-safeproxyfetch branch from 5cccad2 to 9140789 Compare July 21, 2026 10:46
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.

1 participant