Skip to content

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

Merged
willchen96 merged 4 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/sec-ssrf
Aug 2, 2026
Merged

[Security 1/9] SSRF guardrails for server-side connector fetches#242
willchen96 merged 4 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/sec-ssrf

Conversation

@amal66

@amal66 amal66 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

[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
Loading

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
Loading

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.tsvalidateRemoteMcpUrl, 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

@CLAassistant

CLAassistant commented Jul 23, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

amal66 and others added 3 commits July 25, 2026 14:30
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
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
@amal66
amal66 force-pushed the olp-pr/sec-ssrf branch from b5df6c4 to e639dbf Compare July 25, 2026 21:31
@amal66
amal66 requested a review from willchen96 July 26, 2026 14:09
@willchen96

Copy link
Copy Markdown
Collaborator

Implemented the requested review fixes in commit [65cbf0e](https://github.com/Open-Legal-Products/mike/pull/242/commits/65cbf0eef60e88dd584af2d85f94fbec1dcc4ebf):

  • Hardened IPv4/IPv6 classification to fail closed and block non-global ranges, including fec0::/10, multicast, documentation, transition, and reserved addresses.
  • Replaced the per-request Undici Agent with a shared guarded agent, preserving connection-time DNS validation while enabling connection pooling and avoiding unmanaged agent/socket churn.
  • Added regression coverage for private/reserved address classification, malformed addresses, NAT64 cases, and dispatcher reuse.

Verification completed:

  • TypeScript build passed.
  • Focused SSRF tests: 53 passed.
  • Full backend suite: 312 passed, 5 skipped.
  • Live guarded HTTPS smoke test returned 200.

@willchen96 willchen96 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@willchen96
willchen96 merged commit 57bd877 into Open-Legal-Products:main Aug 2, 2026
5 checks passed
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.

3 participants