Skip to content

fix: resolve DNS before SSRF validation to prevent rebinding bypass#386

Open
YizukiAme wants to merge 1 commit intoTHU-MAIC:mainfrom
YizukiAme:fix/ssrf-dns-rebinding
Open

fix: resolve DNS before SSRF validation to prevent rebinding bypass#386
YizukiAme wants to merge 1 commit intoTHU-MAIC:mainfrom
YizukiAme:fix/ssrf-dns-rebinding

Conversation

@YizukiAme
Copy link
Copy Markdown
Contributor

@YizukiAme YizukiAme commented Apr 9, 2026

Summary

Fix SSRF guard DNS rebinding bypass by resolving hostnames to IP addresses before validation.

Closes #378

Problem

The SSRF guard in lib/server/ssrf-guard.ts only checked hostname strings (e.g. localhost, 192.168.*), allowing attackers to register a domain resolving to a private IP and bypass all private-network checks. This could expose cloud instance metadata (169.254.169.254), internal APIs, and other local services.

Changes

Core Fix (lib/server/ssrf-guard.ts)

  • validateUrlForSSRF is now async — resolves hostnames via dns.promises.lookup with { all: true, verbatim: true } and rejects if any returned address is private/local
  • isPrivateIP() helper covers:
    • IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 0.0.0.0
    • IPv6: ::1, ::, fc00::/7 (ULA), fe80::/10 (link-local), fec0::/10 (deprecated site-local)
    • IPv4-mapped IPv6: ::ffff:127.0.0.1, ::ffff:7f00:0001
    • 6to4 tunnel (2002::/16): extracts embedded IPv4 from bits 16-47
    • Teredo tunnel (2001:0000::/32): extracts XOR-inverted client IPv4 from bits 96-127
  • IP literals detected via net.isIP() — validated directly without DNS lookup, avoiding false positives from "DNS failure = block"
  • Fail-closed — DNS resolution errors or empty results reject the request

Async Propagation (20+ files)

  • resolveModel() and resolveModelFromHeaders() in lib/server/resolve-model.ts are now async
  • All direct validateUrlForSSRF() callers and indirect callers via resolveModel/resolveModelFromHeaders updated with await
  • No behavioral changes — existing production-only gating (NODE_ENV === 'production') and per-route SSRF policies are preserved

Tests (tests/server/ssrf-guard.test.ts)

14 test cases covering:

Category Cases
Public pass-through Public hostname, IPv4 literal, IPv6 literal
Protocol blocking Invalid URL, ftp://, file://, javascript:
Hostname fast-path localhost, .local
Private IPv4 literals 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.x, 0.0.0.0
Private IPv6 literals ::1, fd00::, fe80::, fec0::, ::ffff:127.0.0.1
6to4 tunnel Private embedded (blocked) + public embedded (allowed)
Teredo tunnel Private embedded (blocked) + public embedded (allowed)
DNS rebinding Mocked lookup → 127.0.0.1 → blocked
Mixed DNS answers Public + private → blocked
DNS failure Lookup throws → blocked (fail-closed)

Verification

  • pnpm vitest run tests/server/ssrf-guard.test.ts — 14/14 passed
  • pnpm build — no type errors

Out of Scope

  • TOCTOU / IP pinning: The resolved IP at validation time could differ from the IP at fetch time. Mitigating this requires connecting directly by resolved IP, which is a larger change. Tracked separately.
  • ISATAP tunnel: Extremely rare in practice, deferred.

Assisted by Claude Opus 4.6 and GPT-5.4.

@YizukiAme
Copy link
Copy Markdown
Contributor Author

YizukiAme commented Apr 9, 2026

Follow-up: TOCTOU / IP Pinning

This PR fixes the primary DNS rebinding bypass (hostname-only validation), but a time-of-check-time-of-use (TOCTOU) gap remains:

  1. Validation time: dns.lookup("attacker.com") → resolves to 8.8.8.8 (public) → passes
  2. Fetch time (milliseconds later): system DNS resolves again → attacker's DNS server now returns 127.0.0.1 → connects to localhost

To fully mitigate this, the server would need to pin the resolved IP and connect directly to it (setting Host header manually), bypassing the second DNS resolution. This requires a custom HTTP agent and is a significantly larger change.

Planned follow-up issue (if this PR is merged):

  • Implement IP pinning or connect-by-resolved-IP for all SSRF-guarded fetch paths
  • Also consider covering ISATAP tunnel addresses (::0200:5efe:x.x.x.x)

The current fix already blocks the vast majority of real-world DNS rebinding attacks — exploiting the TOCTOU window requires precise millisecond-level DNS TTL manipulation.

@YizukiAme YizukiAme force-pushed the fix/ssrf-dns-rebinding branch from 9967f02 to 0cd2d91 Compare April 9, 2026 03:57
- Make validateUrlForSSRF async; resolve hostnames via dns.promises.lookup
  with { all: true, verbatim: true } and reject if any address is private
- Add isPrivateIP helper covering IPv4 private ranges, IPv6 loopback/ULA/
  link-local, IPv4-mapped IPv6, deprecated fec0::/10 site-local, 6to4
  tunnel (2002::/16), and Teredo tunnel (2001:0000::/32)
- Detect IP literals via net.isIP() and validate directly without DNS
- Fail closed on DNS resolution errors
- Propagate async to resolveModel, resolveModelFromHeaders, and all 20+
  route-level callers; preserve existing production-only gating
- Add 14 unit tests covering public pass-through, private literals, DNS
  rebinding, mixed DNS answers, tunnel addresses, and DNS failure

Closes THU-MAIC#378
@YizukiAme YizukiAme force-pushed the fix/ssrf-dns-rebinding branch from 0cd2d91 to e7dab7f Compare April 9, 2026 03:59
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.

[Bug]: SSRF guard bypassed via DNS rebinding (hostname-only validation)

1 participant