fix(everything): block SSRF to internal/metadata IPs in gzip-file-as-resource - #4498
fix(everything): block SSRF to internal/metadata IPs in gzip-file-as-resource#4498olaservo wants to merge 8 commits into
Conversation
…resource The gzip-file-as-resource tool fetched a caller-supplied URL with only an optional domain allowlist (empty by default, treated as allow-all) and no IP-range filtering, and followed redirects without re-validation. A prompt-injection-steered URL could drive the server to fetch loopback, private, link-local, and cloud-metadata endpoints (e.g. 169.254.169.254) and return their contents to the caller. Resolve the destination host and refuse non-public IP addresses (loopback, private/RFC1918, link-local/metadata, ULA, multicast, reserved, unspecified), covering IPv4, IPv6, and IPv4-mapped IPv6, and follow redirects manually so every hop is re-validated. This applies regardless of GZIP_ALLOWED_DOMAINS, whose domain-allowlist semantics are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unwrap deprecated IPv4-compatible IPv6 addresses (::a.b.c.d, ::/96) and classify them as IPv4, so forms like [::127.0.0.1] are refused rather than treated as public. Adds test coverage for the IPv4-compatible form and for carrier-grade NAT (100.64.0.0/10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens the everything server’s gzip-file-as-resource tool against SSRF by adding IP-range blocking (for loopback/private/link-local/metadata/reserved ranges) and by replacing automatic redirect following with manual redirects that re-validate the destination host at each hop.
Changes:
- Added host resolution + IPv4/IPv6 (incl. IPv4-mapped IPv6) classifiers to block non-public destinations for
http(s)fetches. - Implemented manual redirect following with per-hop SSRF re-validation and refusal of redirects to non-
http(s)schemes. - Updated server docs and added unit tests for blocked IP literals.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/everything/tools/gzip-file-as-resource.ts | Adds SSRF host/IP validation and guarded manual redirect following for fetches. |
| src/everything/docs/structure.md | Documents SSRF blocking behavior for the gzip tool. |
| src/everything/docs/instructions.md | Notes SSRF blocking (including across redirects) in constraints section. |
| src/everything/tests/tools.test.ts | Adds tests ensuring non-public IP literal targets are refused. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… guard Addresses Copilot review feedback on modelcontextprotocol#4498: - assertPublicHost now races the dns/promises lookup against the fetch AbortSignal via a withAbort helper, so a slow-walking resolver can no longer exceed the tool's documented timeout (DNS was previously awaited outside the AbortSignal). - Add a test that a public URL redirecting to 169.254.169.254 is refused at the second hop before any request is made to the internal target, guarding against regressions in per-hop re-validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Copilot feedback on modelcontextprotocol#4498: restructure the guarded-redirect loop to check GZIP_MAX_REDIRECTS after detecting a redirect but before resolving/validating/following the next hop. Behavior is unchanged (at most GZIP_MAX_REDIRECTS redirects followed), but the final redirect target is no longer parsed and assigned before the loop throws. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Copilot feedback on modelcontextprotocol#4498: - fetchWithGuardedRedirects now cancels each redirect Response body before following the next hop, so undici can release the socket instead of leaving it pinned across the redirect chain. - Add tests that mock dns/promises.lookup to cover the hostname resolution branch of assertPublicHost: a hostname resolving to the metadata IP is refused, and the "any resolved address blocked" rule is exercised with a mixed public/private result set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Copilot feedback on modelcontextprotocol#4498: assertPublicHost now catches dns/promises.lookup failures (ENOTFOUND/EAI_AGAIN, or the abort-signal timeout) and rethrows with the host and URL included, so resolution errors are consistent with the tool's other wrapped errors instead of surfacing Node's raw message without context. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SSRF guard only unwrapped IPv4-mapped (::ffff:a.b.c.d) and deprecated IPv4-compatible (::a.b.c.d) addresses, so other IPv6 encodings of an internal destination were classified as public. The NAT64 well-known prefix is the practical case: wherever a NAT64 gateway is deployed, 64:ff9b::169.254.169.254 reaches the cloud metadata service. Unwrap and re-classify IPv4-translated (::ffff:0:0/96), NAT64 (64:ff9b::/96), and 6to4 (2002::/16), and block the local-use NAT64 prefix (64:ff9b:1::/48), discard-only (100::/64), IETF protocol assignments (2001::/23, which covers Teredo), documentation (2001:db8::/32), SRv6 SIDs (5f00::/16), and deprecated site-local (fec0::/10). This brings the classifier in line with the fetch server's guard, which gets these ranges from Python's ipaddress module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
src/everything/tools/gzip-file-as-resource.ts:488
- Manual redirect handling should only follow the Fetch redirect statuses (301, 302, 303, 307, and 308). This range check also follows responses such as 300 and 304 whenever they contain
Location, changing them from final responses into redirects compared with the previousfetchbehavior.
const isRedirect =
response.status >= 300 &&
response.status < 400 &&
response.headers.has("location");
| if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast | ||
| return false; |
| if (current.protocol === "http:" || current.protocol === "https:") { | ||
| await assertPublicHost(current, signal); | ||
| } | ||
|
|
||
| const response = await fetch(current, { signal, redirect: "manual" }); |
This comment was marked as spam.
This comment was marked as spam.
The IPv6 classifier enumerated the prefixes to block and let anything unlisted through, so reserved space outside global unicast was treated as public: an internal route on 4000::1 passed the guard. Only 2000::/3 is assigned as global unicast, so refuse everything else after the IPv4-embedding forms have been unwrapped, keeping the 2001::/23 and 2001:db8::/32 carve-outs inside it. This replaces most of the explicit prefix list (unique-local, link-local, site-local, multicast, discard-only, local-use NAT64, SRv6) with one rule, and fails closed on prefixes IANA assigns in future rather than allowing them. Reported by Copilot review on modelcontextprotocol#4498. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/everything/tools/gzip-file-as-resource.ts:489
- The validation and connection use separate DNS resolutions:
assertPublicHost()checkslookup()results, thenfetch()resolves the hostname again. An attacker-controlled DNS server can return a public address for the check and an internal/metadata address for the connection, bypassing the SSRF guard. Pin each request to one of the validated addresses (while preserving the original Host header and TLS SNI), and repeat that resolve-and-pin process for every redirect hop.
if (current.protocol === "http:" || current.protocol === "https:") {
await assertPublicHost(current, signal);
}
const response = await fetch(current, { signal, redirect: "manual" });
Description
Adds SSRF protection to the
everythingserver'sgzip-file-as-resourcetool. The tool fetched a caller-suppliedhttp/httpsURL and returned the (compressed) response as a resource. Its only host restriction was an optional domain allowlist (GZIP_ALLOWED_DOMAINS) that is empty by default and treated as "all domains allowed", with no IP-range filtering and automatic redirect following. Because the URL is model-produced and prompt-injection-steerable, the server could be driven to fetch loopback, private, link-local, and cloud-metadata endpoints (e.g.169.254.169.254) and return their contents to the caller.Server Details
@modelcontextprotocol/server-everything)gzip-file-as-resource)Motivation and Context
validateDataURIallowedhttp/https/dataand enforced only the domain allowlist (skipped entirely when empty), andfetchSafelycalledfetch(url)with default redirect following and no loopback/RFC1918/link-local/metadata checks.What this PR does:
assertPublicHost()plus IPv4/IPv6 classifiers that resolve the destination host and refuse non-public addresses (loopback, private/RFC1918, link-local/metadata, ULA, multicast, reserved, unspecified), covering IPv4, IPv6, and IPv4-mapped IPv6 (including the hex form the WHATWG URL parser normalizes to).fetchWithGuardedRedirects(redirect: "manual"), re-validating the host on every hop and refusing redirects to non-http(s) schemes.GZIP_ALLOWED_DOMAINS. The domain-allowlist semantics are intentionally left unchanged (empty still means "all domains"), so the tool's default demo URL keeps working; the allowlist and the SSRF guard are independent controls.How Has This Been Tested?
169.254.169.254, RFC1918 ranges,0.0.0.0, IPv6 loopback, IPv4-mapped IPv6) via IP literals (no network required).npm run build(tsc) passes; fullvitestsuite passes (115 tests, including the 8 new cases).data:URI and public-URL behavior is unchanged and still covered by tests.Breaking Changes
No client configuration changes required. Behavior only changes for requests that targeted non-public IPs, which are now refused (the intended fix).
data:URIs and public URLs are unaffected.Types of changes
Checklist
Additional context
Docs updated in
docs/instructions.mdanddocs/structure.mdto note that internal/metadata IPs are always blocked (and re-validated across redirects) independent of the allowlist. As with any resolve-then-connect guard, a narrow DNS-rebinding window remains; pinning to the validated IP could be added later.🤖 Generated with Claude Code
Prior art
The
gzip-file-as-resourcetool and its originalGZIP_ALLOWED_DOMAINSdomain-allowlist SSRF mitigation were added in #2831 (@ochafik). This PR builds on that baseline with an IP-range guard that applies independently of the domain allowlist.