From 889a3084239a1c4885ebc515eed11fad98f35874 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 8 Jul 2026 07:43:45 -0700 Subject: [PATCH 1/2] fix(everything): block SSRF to internal/metadata IPs in gzip-file-as-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) --- src/everything/__tests__/tools.test.ts | 35 ++++ src/everything/docs/instructions.md | 2 +- src/everything/docs/structure.md | 1 + src/everything/tools/gzip-file-as-resource.ts | 183 +++++++++++++++++- 4 files changed, 218 insertions(+), 3 deletions(-) diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index a50bbd6592..e3e2682945 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -1217,5 +1217,40 @@ describe('Tools', () => { handler!({ name: 'test.gz', data: 'ftp://example.com/file.txt', outputType: 'resource' }) ).rejects.toThrow('Unsupported URL protocol'); }); + + // 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/'], + ['IPv6 loopback', 'http://[::1]/'], + ['IPv4-mapped IPv6 loopback', 'http://[::ffff:127.0.0.1]/'], + ]; + + for (const [label, url] of blockedHosts) { + it(`should refuse to fetch non-public host (${label})`, async () => { + const mockServer = { + registerTool: vi.fn(), + registerResource: vi.fn(), + } as unknown as McpServer; + + let handler: Function | null = null; + (mockServer.registerTool as any).mockImplementation( + (name: string, config: any, h: Function) => { + handler = h; + } + ); + + registerGZipFileAsResourceTool(mockServer); + + await expect( + handler!({ name: 'test.gz', data: url, outputType: 'resource' }) + ).rejects.toThrow(/SSRF protection/); + }); + } }); }); diff --git a/src/everything/docs/instructions.md b/src/everything/docs/instructions.md index 5806dc0ba9..7bb65d0878 100644 --- a/src/everything/docs/instructions.md +++ b/src/everything/docs/instructions.md @@ -12,7 +12,7 @@ Follow them to use, extend, and troubleshoot the server safely and effectively. ## Constraints & Limitations -- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS` +- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS`. Requests to loopback, private, link-local, and cloud-metadata IP addresses are always blocked (SSRF protection), including across redirects, regardless of the allowlist. - Session resources are ephemeral and lost when the session ends - Sampling requests (`trigger-sampling-request`) require client sampling capability - Elicitation requests (`trigger-elicitation-request`) require client elicitation capability diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md index bd3d70b95c..af9b802d12 100644 --- a/src/everything/docs/structure.md +++ b/src/everything/docs/structure.md @@ -151,6 +151,7 @@ src/everything - `GZIP_MAX_FETCH_SIZE` (bytes, default 10 MiB) - `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000) - `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed) + - SSRF protection: loopback, private (RFC1918), link-local, and cloud-metadata IP addresses are always refused (and re-validated on every redirect hop), independent of `GZIP_ALLOWED_DOMAINS`. - `simulate-research-query.ts` - Registers a `simulate-research-query` task-based tool that demonstrates the MCP Tasks feature (SEP-1686). Simulates a multi-stage research operation with progress updates. If the query is marked as ambiguous and the client supports elicitation, it pauses mid-execution to request clarification via `elicitation/create`. Uses `server.experimental.tasks.registerToolTask()` with `execution: { taskSupport: "required" }`. - `trigger-elicitation-request.ts` diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index 3dd6fdae4a..cf9d215d47 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -2,11 +2,16 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolResult, Resource } from "@modelcontextprotocol/sdk/types.js"; import { gzipSync } from "node:zlib"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { getSessionResourceURI, registerSessionResource, } from "../resources/session.js"; +// Maximum number of redirect hops to follow (and re-validate) when fetching. +const GZIP_MAX_REDIRECTS = 20; + // Maximum input file size - 10 MB default const GZIP_MAX_FETCH_SIZE = Number( process.env.GZIP_MAX_FETCH_SIZE ?? String(10 * 1024 * 1024) @@ -167,6 +172,133 @@ function validateDataURI(dataUri: string): URL { return url; } +/** + * Determines whether an IPv4 address string falls in a range that must not be + * fetched (loopback, private, link-local/cloud-metadata, reserved, etc.). + */ +function isBlockedIpv4(ip: string): boolean { + const parts = ip.split(".").map((p) => parseInt(p, 10)); + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) { + // Not a well-formed IPv4 address; treat as blocked to fail closed. + return true; + } + const asInt = + ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + const inRange = (base: string, bits: number): boolean => { + const b = base.split(".").map((p) => parseInt(p, 10)); + const baseInt = ((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]) >>> 0; + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (asInt & mask) === (baseInt & mask); + }; + return ( + inRange("0.0.0.0", 8) || // "this" network / unspecified + inRange("10.0.0.0", 8) || // private + inRange("100.64.0.0", 10) || // carrier-grade NAT + inRange("127.0.0.0", 8) || // loopback + inRange("169.254.0.0", 16) || // link-local (incl. cloud metadata 169.254.169.254) + inRange("172.16.0.0", 12) || // private + inRange("192.0.0.0", 24) || // IETF protocol assignments + inRange("192.0.2.0", 24) || // TEST-NET-1 + inRange("192.168.0.0", 16) || // private + inRange("198.18.0.0", 15) || // benchmarking + inRange("198.51.100.0", 24) || // TEST-NET-2 + inRange("203.0.113.0", 24) || // TEST-NET-3 + inRange("224.0.0.0", 4) || // multicast + inRange("240.0.0.0", 4) // reserved (incl. 255.255.255.255) + ); +} + +/** + * Expands an IPv6 address string (possibly using "::" compression and/or a + * trailing dotted-quad IPv4 suffix) into its 8 16-bit hextets. Returns null if + * the address cannot be parsed. + */ +function expandIpv6(addr: string): number[] | null { + let s = addr; + // Convert a trailing IPv4 dotted-quad (e.g. ::ffff:127.0.0.1) into hextets. + const v4match = s.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/); + if (v4match) { + const v4 = v4match[2].split(".").map((p) => parseInt(p, 10)); + if (v4.length !== 4 || v4.some((n) => Number.isNaN(n) || n < 0 || n > 255)) { + return null; + } + const h1 = ((v4[0] << 8) | v4[1]).toString(16); + const h2 = ((v4[2] << 8) | v4[3]).toString(16); + s = `${v4match[1]}${h1}:${h2}`; + } + + const halves = s.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + if (halves.length === 1) { + if (head.length !== 8) return null; + return head.map((g) => parseInt(g, 16)); + } + const missing = 8 - head.length - tail.length; + if (missing < 0) return null; + const groups = [...head, ...Array(missing).fill("0"), ...tail]; + if (groups.length !== 8) return null; + return groups.map((g) => parseInt(g || "0", 16)); +} + +/** + * Determines whether an IPv6 address string must not be fetched. IPv4-mapped + * addresses (in either dotted or hex form) are unwrapped and classified as IPv4. + */ +function isBlockedIpv6(ip: string): boolean { + const g = expandIpv6(ip.toLowerCase()); + if (!g || g.some((h) => Number.isNaN(h))) { + return true; // fail closed on anything we cannot parse + } + // IPv4-mapped (::ffff:a.b.c.d): first 80 bits zero, next 16 bits 0xffff. + if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff) { + const v4 = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`; + return isBlockedIpv4(v4); + } + if (g.every((h) => h === 0)) return true; // :: unspecified + if (g.slice(0, 7).every((h) => h === 0) && g[7] === 1) return true; // ::1 loopback + const first = g[0]; + if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local + if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast + return false; +} + +/** + * Resolves a URL's host and throws if any resolved address is a non-public + * (loopback/private/link-local/metadata) IP, to prevent SSRF. Only http/https + * URLs are checked; other schemes (e.g. data:) are left to the caller. + * + * @param {URL} url The URL whose destination host should be validated. + * @throws {Error} If the host resolves to a blocked address or cannot be resolved. + */ +async function assertPublicHost(url: URL): Promise { + // url.hostname keeps brackets around IPv6 literals; strip them. + const host = url.hostname.replace(/^\[|\]$/g, ""); + + let addresses: string[]; + if (isIP(host)) { + addresses = [host]; + } else { + const resolved = await lookup(host, { all: true }); + addresses = resolved.map((r) => r.address); + if (addresses.length === 0) { + throw new Error(`Could not resolve host ${host} for ${url}`); + } + } + + for (const address of addresses) { + const blocked = + isIP(address) === 6 ? isBlockedIpv6(address) : isBlockedIpv4(address); + if (blocked) { + throw new Error( + `Refusing to fetch ${url}: host ${host} resolves to non-public address ${address} (SSRF protection).` + ); + } + } +} + /** * Fetches data safely from a given URL while ensuring constraints on maximum byte size and timeout duration. * @@ -191,8 +323,9 @@ async function fetchSafely( ); try { - // Fetch the data - const response = await fetch(url, { signal: controller.signal }); + // Fetch the data, following redirects manually so every hop is re-validated + // against the SSRF guard (automatic redirects would bypass it). + const response = await fetchWithGuardedRedirects(url, controller.signal); if (!response.body) { throw new Error("No response body"); } @@ -246,3 +379,49 @@ async function fetchSafely( clearTimeout(timeout); } } + +/** + * Performs a fetch that follows redirects manually, validating the destination + * host against the SSRF guard before every hop. Non-http(s) URLs (e.g. data:) + * are fetched without host validation, and redirects to non-http(s) schemes are + * refused. + * + * @param {URL} url The initial URL to fetch. + * @param {AbortSignal} signal The abort signal used to enforce the fetch timeout. + * @return {Promise} The final (non-redirect) response. + * @throws {Error} If a hop resolves to a blocked host, a redirect targets an + * unsupported scheme, or the redirect limit is exceeded. + */ +async function fetchWithGuardedRedirects( + url: URL, + signal: AbortSignal +): Promise { + let current = url; + for (let hop = 0; hop <= GZIP_MAX_REDIRECTS; hop++) { + if (current.protocol === "http:" || current.protocol === "https:") { + await assertPublicHost(current); + } + + const response = await fetch(current, { signal, redirect: "manual" }); + + const isRedirect = + response.status >= 300 && + response.status < 400 && + response.headers.has("location"); + if (!isRedirect) { + return response; + } + + const next = new URL(response.headers.get("location")!, current); + if (next.protocol !== "http:" && next.protocol !== "https:") { + throw new Error( + `Refusing to follow redirect from ${current} to unsupported protocol ${next.protocol}` + ); + } + current = next; + } + + throw new Error( + `Too many redirects while fetching ${url} (max ${GZIP_MAX_REDIRECTS}).` + ); +} From 75cf52905794ce57dbe6bc7f833c6c478ae91d0e Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 10 Jul 2026 08:00:20 -0700 Subject: [PATCH 2/2] fix(everything): block IPv4-compatible IPv6 (::/96) SSRF targets 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) --- src/everything/__tests__/tools.test.ts | 2 ++ src/everything/tools/gzip-file-as-resource.ts | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index e3e2682945..08f158c4b7 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -1227,8 +1227,10 @@ describe('Tools', () => { ['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]/'], ]; for (const [label, url] of blockedHosts) { diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index cf9d215d47..1e90f9606d 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -258,6 +258,12 @@ function isBlockedIpv6(ip: string): boolean { } if (g.every((h) => h === 0)) return true; // :: unspecified if (g.slice(0, 7).every((h) => h === 0) && g[7] === 1) return true; // ::1 loopback + // IPv4-compatible (deprecated ::a.b.c.d, ::/96): first 96 bits zero. Unwrap + // and classify as IPv4 so forms like ::127.0.0.1 are not treated as public. + if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { + const v4 = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`; + return isBlockedIpv4(v4); + } const first = g[0]; if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local