Skip to content

fix: eliminate DNS rebinding TOCTOU and redirect bypass in SSRF protection (CWE-918) - #45

Closed
sebastionoss wants to merge 1 commit into
zcaceres:mainfrom
sebastionoss:fix/cwe918-fetcher-ssrf-8bea
Closed

fix: eliminate DNS rebinding TOCTOU and redirect bypass in SSRF protection (CWE-918)#45
sebastionoss wants to merge 1 commit into
zcaceres:mainfrom
sebastionoss:fix/cwe918-fetcher-ssrf-8bea

Conversation

@sebastionoss

Copy link
Copy Markdown

Vulnerability Summary

CWE-918: Server-Side Request Forgery (SSRF)
Severity: High
Affected file: src/Fetcher.ts_fetch() method and validateResolvedIp() helper

Data Flow

AI tool call (user/prompt-influenced URL)
  → Fetcher.html() / .json() / .txt() / .markdown() / .readable()
    → _fetch({ url })
      → validateResolvedIp(url)   // DNS lookup #1 — validates IP
      → fetch(url)                // DNS lookup #2 — may resolve to DIFFERENT IP

The URL parameter originates from MCP tool calls, which can be influenced by user input or prompt injection. There is no authentication layer, no allowlist, and no network-level mitigation between the tool call and the outbound request.

Two Distinct Attack Vectors

1. DNS Rebinding TOCTOU (Full Read SSRF)

validateResolvedIp() performs DNS resolution and validates the IP, but fetch() resolves DNS again internally. An attacker controlling a DNS server with TTL=0 can return a public IP for validation and a private IP (e.g., 169.254.169.254 for cloud metadata) for the actual fetch. This is a classic Time-of-Check-to-Time-of-Use race condition.

2. Redirect Bypass (Blind SSRF)

The original code follows redirects automatically (fetch() default behavior) and only validates response.url after the request has completed. A redirect to an internal IP (e.g., http://127.0.0.1:8080/admin/delete-all) executes the request and potentially triggers side effects before the post-check throws an error.

Exploit Sketch

DNS Rebinding:

1. Attacker controls evil.com DNS with TTL=0
2. AI tool call: fetch_html({url: "http://evil.com/latest/meta-data/..."})
3. validateResolvedIp: DNS lookup #1 → 93.184.216.34 (public) → passes
4. fetch(url): DNS lookup #2 → 169.254.169.254 (metadata) → connects
5. AWS credentials returned to AI and shown to attacker

Redirect Bypass:

1. redirect.evil.com returns 302 → http://127.0.0.1:8080/admin/delete-all
2. AI tool call: fetch_html({url: "http://redirect.evil.com/go"})
3. validateResolvedIp: resolves redirect.evil.com → public IP → passes
4. fetch() follows redirect automatically → internal request executes
5. Post-check detects 127.0.0.1 → throws error (but request already completed)

Preconditions

  • Attacker can influence the URL parameter (via user input or prompt injection to the AI)
  • MCP server runs on a machine with access to internal network or cloud metadata
  • For DNS rebinding: attacker controls a DNS server (free services like rebind.network exist)
  • For redirect bypass: attacker controls any web server (trivial)

Fix Description

Approach: IP Pinning + Manual Redirect Following

The fix replaces the vulnerable validate-then-fetch pattern with a resolve-once-use-once approach:

  1. resolveAndPin(url) — New method that resolves DNS once, validates the IP is not private, then rewrites the URL to use the resolved IP directly (e.g., http://93.184.216.34:8080/path). Returns both the pinned URL and the original hostname for Host header / TLS SNI.

  2. Manual redirect handlingfetch() is called with redirect: "manual". Each redirect hop is validated and pinned before following, in a bounded loop (max 20 redirects). This ensures no request to a private IP is ever made, even via redirect chains.

  3. Host header and TLS SNI preservation — Since the URL hostname is replaced with an IP, the original Host header is explicitly set, and TLS serverName is set for HTTPS to ensure proper certificate validation and virtual hosting.

Why this approach?

IP pinning is the OWASP-recommended defense against DNS rebinding TOCTOU in SSRF protections. Manual redirect following with per-hop validation is the standard defense against redirect-based bypasses. Both are well-established patterns.

Changes

File Change
src/Fetcher.ts Replaced validateResolvedIp() with resolveAndPin(). Rewrote _fetch() to use manual redirect loop with per-hop validation. Added MAX_REDIRECTS constant.
src/Fetcher.test.ts Updated existing redirect tests to match new manual redirect behavior (mock responses now use Response objects with status 302 + Location header instead of response.url).
src/Fetcher.ssrf.test.ts New file — 6 targeted SSRF PoC tests covering DNS rebinding IP pinning, redirect validation, redirect chain limits, IPv6 pinning, and existing private-IP blocking.

Test Results

All 107 tests pass (101 existing + 6 new SSRF-specific):

✓ CWE-918: DNS rebinding TOCTOU > should pin resolved IP: fetch URL uses validated IP, not hostname
✓ CWE-918: DNS rebinding TOCTOU > should validate redirect targets BEFORE following them
✓ CWE-918: DNS rebinding TOCTOU > should enforce redirect limit to prevent infinite loops
✓ CWE-918: DNS rebinding TOCTOU > should set Host header to original hostname when IP-pinning
✓ CWE-918: DNS rebinding TOCTOU > should handle IPv6 resolved addresses correctly
✓ CWE-918: DNS rebinding TOCTOU > should still block direct requests to private IPs

Existing tests for HTML fetching, JSON parsing, Readability, YouTube transcripts, URL validation, and content-length limits continue to pass.


Disprove Analysis

To ensure this is a genuine finding, we systematically attempted to disprove it across 8 dimensions:

Check Result
Authentication No auth in Fetcher.ts or index.ts. MCP protocol relies on host app trust. URL comes directly from AI tool calls. No mitigation.
Network binding MCP server uses stdio, not HTTP. No CORS, no ALLOWED_HOSTS. Makes outbound requests from user's machine. No mitigation.
Deployment No Docker/k8s. Runs via npx mcp-fetch-server on user machine or VM. No reverse proxy.
Caller trace _fetch() called by 7 public MCP tools. URL param comes from AI tool call (Zod-validated as z.string().url() — schema only, no content validation). Attacker-controlled data reaches vulnerable function.
Existing validation validateUrl() blocks localhost, private IPs, non-HTTP protocols. validateResolvedIp() does DNS lookup + private IP check — but vulnerable to TOCTOU (two DNS lookups). Redirect check fires after request completes.
Prior reports PR #40 (CVE-2025-8020 multicast bypass in private-ip), PR #44 (proxy parameter bypass). No prior report for DNS rebinding TOCTOU or redirect bypass specifically.
Security policy No SECURITY.md found.
Recent commits 1 commit on main touching Fetcher.ts (chore: bump to v1.1.2). DNS rebinding fix not previously applied.

Fix Adequacy Note

The proxy parameter (...(proxy ? { proxy } : {})) provides a separate SSRF bypass path that is not addressed by this fix — it is the subject of a separate report (PR #44). These are distinct vulnerability classes: even with the proxy fix applied, DNS rebinding and redirect bypass would remain exploitable without this fix. Conversely, this fix closes attack vectors that cannot be reached via proxy bypass.

Prior Art

DNS rebinding TOCTOU is well-documented:

  • CVE-2023-37266 (CasaOS SSRF via DNS rebinding)
  • OWASP SSRF Prevention Cheat Sheet recommends IP pinning
  • The "resolve → validate → fetch (re-resolves)" pattern is a known vulnerability class

Verdict: CONFIRMED_VALID | Confidence: High


Diff Summary

 src/Fetcher.ssrf.test.ts | 188 ++++++++++++++++++++++++++++++++++++
 src/Fetcher.test.ts      |  97 ++++++++++---------
 src/Fetcher.ts           | 141 +++++++++++++++++++---------
 3 files changed, 362 insertions(+), 64 deletions(-)

Thank you for maintaining fetch-mcp! Happy to discuss any aspect of this fix or adjust the approach if you have preferences.

Resolve DNS once via resolveAndPin(), validate the IP is not private,
then rewrite the URL to use the validated IP directly with appropriate
Host header and TLS SNI. This prevents DNS rebinding attacks where a
second DNS resolution (inside fetch) could return a different IP.

Also switches to manual redirect handling so every redirect hop is
validated (protocol, hostname, and resolved IP) before the request
is made, preventing SSRF via open redirects.

CWE-918
@sebastionoss

Copy link
Copy Markdown
Author

Closing this as inactive — no maintainer response after 30 days.

The security finding and fix remain valid. If this is still relevant, I'm happy to reopen, rebase, or re-submit against a different branch. Just drop a comment.

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