Skip to content

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

Open
olaservo wants to merge 4 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 4 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/everything/tools/gzip-file-as-resource.ts
Comment thread src/everything/__tests__/tools.test.ts
… 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/everything/tools/gzip-file-as-resource.ts
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +447 to +453
const isRedirect =
response.status >= 300 &&
response.status < 400 &&
response.headers.has("location");
if (!isRedirect) {
return response;
}
Comment on lines +1221 to +1234
// SSRF protection: the tool must refuse to fetch non-public IP addresses.
// These use IP literals so no DNS resolution (or network) is required.
const blockedHosts: Array<[string, string]> = [
['loopback IPv4', 'http://127.0.0.1/secret'],
['cloud metadata', 'http://169.254.169.254/latest/meta-data/'],
['private 10/8', 'http://10.0.0.1/'],
['private 192.168/16', 'http://192.168.1.1/'],
['private 172.16/12', 'http://172.16.0.1/'],
['unspecified', 'http://0.0.0.0/'],
['carrier-grade NAT 100.64/10', 'http://100.64.0.1/'],
['IPv6 loopback', 'http://[::1]/'],
['IPv4-mapped IPv6 loopback', 'http://[::ffff:127.0.0.1]/'],
['IPv4-compatible IPv6 loopback', 'http://[::127.0.0.1]/'],
];
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.

2 participants