Skip to content

fix(everything): block SSRF to internal/metadata IPs in gzip-file-as-resource#4498

Open
olaservo wants to merge 2 commits into
modelcontextprotocol:mainfrom
olaservo:fix/everything-gzip-ssrf
Open

fix(everything): block SSRF to internal/metadata IPs in gzip-file-as-resource#4498
olaservo wants to merge 2 commits into
modelcontextprotocol:mainfrom
olaservo:fix/everything-gzip-ssrf

Conversation

@olaservo

@olaservo olaservo commented Jul 8, 2026

Copy link
Copy Markdown
Member

Description

Adds SSRF protection to the everything server's gzip-file-as-resource tool. The tool fetched a caller-supplied http/https URL 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

  • Server: everything (TypeScript, @modelcontextprotocol/server-everything)
  • Changes to: tools (gzip-file-as-resource)

Motivation and Context

validateDataURI allowed http/https/data and enforced only the domain allowlist (skipped entirely when empty), and fetchSafely called fetch(url) with default redirect following and no loopback/RFC1918/link-local/metadata checks.

What this PR does:

  • Adds 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).
  • Replaces automatic redirect following with fetchWithGuardedRedirects (redirect: "manual"), re-validating the host on every hop and refusing redirects to non-http(s) schemes.
  • The IP guard applies regardless of 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?

  • New unit tests assert the tool refuses non-public hosts (loopback, 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; full vitest suite passes (115 tests, including the 8 new cases).
  • Existing 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Protocol Documentation
  • My changes follows MCP security best practices
  • I have updated the server's README accordingly
  • I have tested this with an LLM client
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have documented all environment variables and configuration options

Additional context

Docs updated in docs/instructions.md and docs/structure.md to 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

olaservo and others added 2 commits July 8, 2026 07:43
…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>
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