[Security 1/9] SSRF guardrails for server-side connector fetches

✅ merged · #242 · open-legal-products/mike ← amal66/mike · opened 3mo ago by amal66 · merged 3mo ago by willchen96 · +450-48 across 7 files · ↗ on GitHub

From the PR description

[Security 1/9] SSRF guardrails for server-side connector fetches

Part of the split of #227 into reviewable, single-topic PRs. Each stands alone and is scoped to one attack class. Index: see the tracking comment on #227.

TL;DR

When a user registers an MCP connector, our server fetches the URL they gave us. This PR stops that fetch from being pointed at internal infrastructure (the cloud metadata service, the database, admin panels) by validating the resolved IP address against every private/reserved range and failing closed. It also closes the DNS-rebinding race by pinning the connection to the address we validated.


Risk to user data

Severity: critical. A successful SSRF here is a direct path to total data compromise, not just one user's records:

  • The cloud metadata endpoint (http://169.254.169.254/...) hands out the server's IAM credentials. With those, an attacker reads the entire object store and database - every tenant's documents, not their own.
  • Internal addresses (localhost:5432, private admin panels) are reachable from the server but not from the public internet, so this bypasses the network perimeter entirely.

The attacker only needs an ordinary app account and the "add a connector" feature.

Flows affected

  • MCP connector registration / validation (validateRemoteMcpUrl)
  • Every outbound connector transport fetch
  • OAuth discovery, dynamic client registration, and token refresh for connectors (mcp/oauth.ts) - previously these used raw fetch and were unguarded SSRF sinks.

Attack precedent

  • Capital One, 2019 - 106M records. An SSRF reached 169.254.169.254, retrieved IAM role credentials, and used them to read S3. This exact primitive. (Krebs)
  • SSRF is its own category in the OWASP Top 10 (A10:2021).
sequenceDiagram
    participant A as Attacker (ordinary user)
    participant S as Mike backend (inside the VPC)
    participant M as Metadata service<br/>169.254.169.254
    A->>S: Register connector<br/>url = http://169.254.169.254/latest/meta-data/
    S->>M: server-side GET (from inside the VPC)
    M-->>S: IAM credentials
    S-->>A: response relayed back
    Note over S,M: With this PR: 169.254.169.254 is link-local →<br/>refused before a socket opens

Possible fixes, and what we chose

Option Why not / why
Blocklist hostnames (localhost, metadata) Trivially bypassed: raw IPs, DNS names that resolve to internal IPs, 0x7f.0.0.1, IPv6 forms. Rejected.
Allowlist of known-good connector domains Safest, but MCP is explicitly "bring your own server" - an allowlist defeats the feature. Rejected for now (a good future opt-in for locked-down tenants).
Validate the resolved IP against all private/reserved ranges, fail closed Chosen. Works for arbitrary user URLs, no maintenance of a domain list. The subtlety is doing it correctly - see below.

The correct-implementation details that matter:

  • Validate the resolved IP, not the URL string. http://internal.evil.com/ looks public but resolves to 10.0.0.5.
  • Cover every reserved range: loopback, RFC1918, link-local, CGNAT (100.64/10), multicast, and the IPv6 forms of all of them - including IPv4 addresses embedded in IPv6 literals (::ffff:10.0.0.1, ::7f00:1), the classic filter bypass. privateIp.ts expands these.
  • Fail closed: anything unparseable or unrecognized is treated as blocked.
  • Never auto-follow redirects. A public URL that 302s to http://localhost/ defeats a naive check - so guardedFetch sets redirect: "manual".

The tests in client.ssrf.test.ts encode the bypass encodings, not just the happy path.

The subtle part: DNS rebinding (a TOCTOU race)

Validating the IP and then fetching the URL is two separate DNS lookups. An attacker who controls DNS for evil.example (TTL 0) answers the first lookup - the validation - with a harmless public IP, and the second lookup - the real fetch - with 127.0.0.1. This is a classic time-of-check-to-time-of-use race: the thing you checked is not the thing you used.

sequenceDiagram
    participant D as Attacker DNS (TTL 0)
    participant S as Naive server
    S->>D: resolve evil.example (validation)
    D-->>S: 93.184.216.34 - public, check passes ✅
    S->>D: resolve evil.example (actual fetch)
    D-->>S: 127.0.0.1 - check already passed ❌
    S->>S: connects to 127.0.0.1

Fix: resolve once, validate those addresses, and connect to exactly them. This PR does it with a per-request undici Agent whose connect.lookup hook runs the private-IP guard at the moment the socket opens and hands undici only validated addresses - so the address we validate is the address we connect to. The URL hostname is untouched, so the Host header and TLS SNI still reflect the real host and certificate verification works normally against legitimate public servers.

What's in this PR

  • backend/src/lib/privateIp.ts - conservative IP classifier (new, shared).
  • backend/src/lib/mcp/client.ts - validateRemoteMcpUrl, guardedFetch, and the connect-time-pinned undici dispatcher.
  • backend/src/lib/mcp/oauth.ts - routes all discovery/registration/refresh through guardedFetch.
  • undici dependency; tests in client.ssrf.test.ts (9 tests, incl. embedded-IPv4-in-IPv6 bypasses).

Reading

PortSwigger: SSRF · OWASP SSRF Prevention Cheat Sheet

Our analysis

Harden connector fetches against SSRF — read the full analysis →

Think the analysis missed something the PR description covers?

Commits in this PR (4)

SHA Subject Author Date
22169c14 security: SSRF guardrails for server-side connector fetches Amalanand Muthukumaran 2026-07-25 ↗ GitHub
commit body
Port the fork's SSRF hardening for MCP connector egress into the
upstream layout:

- Extract private/reserved IP classification into lib/privateIp.ts and
  fix IPv6 gaps: fe80::/10 link-local matching (the /^fe[89ab]:/ regex
  only matched the hextet "fe8:" and let fe80::1 through), hex-form
  IPv4-mapped addresses (::ffff:a00:1), NAT64 (64:ff9b::/96) and 6to4
  (2002::/16) embedded IPv4 ranges.
- Strip brackets from IPv6 literals in validateRemoteMcpUrl so [::1]
  et al. are classified by the private-IP guard instead of falling
  through to DNS lookup.
- Route all OAuth egress (metadata fetch, discovery probes, dynamic
  client registration, token refresh) through guardedFetch so every
  outbound MCP request gets the same HTTPS-only / blocked-host /
  private-IP / no-redirect checks; the discovery probes were previously
  raw, unvalidated fetches.
- Add SSRF regression tests (run atop the test-harness PR) and exclude
  test files from the tsc production build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
f28d9d14 security: pin MCP egress DNS at connect time (undici dispatcher) Amalanand Muthukumaran 2026-07-25 ↗ GitHub
commit body
Restore the fork's pinnedGuardAgent verbatim: guardedFetch now routes
through a per-request undici Agent whose connect-time DNS lookup runs
the private-IP guard and returns only validated addresses, so the
address we validate is the address we connect to - closing the
DNS-rebinding/TOCTOU window between the pre-fetch validation lookup and
the socket's own resolution. Adds the undici runtime dependency the
fork ships for exactly this purpose ("undici": "^6.27.0"), and restores
the dispatcher assertions in the SSRF test so it matches the fork's
byte for byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
e639dbfc security: block IPv4-compatible IPv6 embeds in the private-IP guard Amalanand Muthukumaran 2026-07-25 ↗ GitHub
65cbf0ee security: harden SSRF IP guard and reuse dispatcher willchen96 2026-08-03 ↗ GitHub

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-242.md from inside the repo you want the changes in.

⬇ Download capture-pull-242.md