From 889a3084239a1c4885ebc515eed11fad98f35874 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 8 Jul 2026 07:43:45 -0700 Subject: [PATCH 1/8] 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/8] 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 From d2d2ef81de16b9f57192e692630dbdc8cd36daa1 Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 14 Jul 2026 06:48:58 -0700 Subject: [PATCH 3/8] fix(everything): bound DNS resolution to fetch timeout; test redirect guard Addresses Copilot review feedback on #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) --- src/everything/__tests__/tools.test.ts | 42 +++++++++++++++++++ src/everything/tools/gzip-file-as-resource.ts | 40 ++++++++++++++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index 08f158c4b7..f29a3c8a7b 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -1254,5 +1254,47 @@ describe('Tools', () => { ).rejects.toThrow(/SSRF protection/); }); } + + it('should re-validate redirects and refuse a public URL that redirects to a blocked IP', 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); + + // First (public) hop responds with a redirect to the cloud-metadata IP. + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: 'http://169.254.169.254/latest/meta-data/' }, + }) + ); + + try { + await expect( + handler!({ + name: 'test.gz', + // Public IP literal so the first hop needs no DNS resolution. + data: 'http://93.184.216.34/', + outputType: 'resource', + }) + ).rejects.toThrow(/SSRF protection/); + + // The blocked redirect target must be rejected before any request is + // made to it: only the initial public URL was fetched. + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0][0])).toBe('http://93.184.216.34/'); + } finally { + fetchSpy.mockRestore(); + } + }); }); }); diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index 1e90f9606d..82ee95b9af 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -271,15 +271,45 @@ function isBlockedIpv6(ip: string): boolean { return false; } +/** + * Rejects as soon as `signal` aborts so an awaited operation that does not + * itself honor the AbortSignal (e.g. DNS resolution via dns/promises.lookup) + * still respects the overall fetch timeout instead of hanging past it. + */ +function withAbort( + promise: Promise, + signal: AbortSignal | undefined, + message: string +): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(new Error(message)); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error(message)); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} + /** * 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. + * @param {AbortSignal} [signal] Abort signal that also bounds DNS resolution to + * the caller's timeout. * @throws {Error} If the host resolves to a blocked address or cannot be resolved. */ -async function assertPublicHost(url: URL): Promise { +async function assertPublicHost(url: URL, signal?: AbortSignal): Promise { // url.hostname keeps brackets around IPv6 literals; strip them. const host = url.hostname.replace(/^\[|\]$/g, ""); @@ -287,7 +317,11 @@ async function assertPublicHost(url: URL): Promise { if (isIP(host)) { addresses = [host]; } else { - const resolved = await lookup(host, { all: true }); + const resolved = await withAbort( + lookup(host, { all: true }), + signal, + `Timed out resolving host ${host} for ${url}.` + ); addresses = resolved.map((r) => r.address); if (addresses.length === 0) { throw new Error(`Could not resolve host ${host} for ${url}`); @@ -405,7 +439,7 @@ async function fetchWithGuardedRedirects( let current = url; for (let hop = 0; hop <= GZIP_MAX_REDIRECTS; hop++) { if (current.protocol === "http:" || current.protocol === "https:") { - await assertPublicHost(current); + await assertPublicHost(current, signal); } const response = await fetch(current, { signal, redirect: "manual" }); From a11531128df63c131cd276c46c7e0716bae9e3b9 Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 14 Jul 2026 08:29:55 -0700 Subject: [PATCH 4/8] refactor(everything): throw at redirect limit before following next hop Addresses Copilot feedback on #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) --- src/everything/tools/gzip-file-as-resource.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index 82ee95b9af..ccda55d659 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -437,7 +437,7 @@ async function fetchWithGuardedRedirects( signal: AbortSignal ): Promise { let current = url; - for (let hop = 0; hop <= GZIP_MAX_REDIRECTS; hop++) { + for (let redirects = 0; ; redirects++) { if (current.protocol === "http:" || current.protocol === "https:") { await assertPublicHost(current, signal); } @@ -452,6 +452,14 @@ async function fetchWithGuardedRedirects( return response; } + // Enforce the limit before following (and re-validating) another hop, so + // we never resolve or fetch a redirect target beyond GZIP_MAX_REDIRECTS. + if (redirects >= GZIP_MAX_REDIRECTS) { + throw new Error( + `Too many redirects while fetching ${url} (max ${GZIP_MAX_REDIRECTS}).` + ); + } + const next = new URL(response.headers.get("location")!, current); if (next.protocol !== "http:" && next.protocol !== "https:") { throw new Error( @@ -460,8 +468,4 @@ async function fetchWithGuardedRedirects( } current = next; } - - throw new Error( - `Too many redirects while fetching ${url} (max ${GZIP_MAX_REDIRECTS}).` - ); } From b688426ec95b6d9ac372751f2f95cb462caee254 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 19 Jul 2026 19:51:36 -0700 Subject: [PATCH 5/8] fix(everything): drain redirect bodies; test hostname SSRF path Addresses Copilot feedback on #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) --- src/everything/__tests__/tools.test.ts | 68 +++++++++++++++++++ src/everything/tools/gzip-file-as-resource.ts | 5 ++ 2 files changed, 73 insertions(+) diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index f29a3c8a7b..bcb441a060 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -20,6 +20,15 @@ import { import { registerGetRootsListTool } from '../tools/get-roots-list.js'; import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js'; import { registerSimulateResearchQueryTool } from '../tools/simulate-research-query.js'; +import { lookup } from 'node:dns/promises'; + +// Mock DNS resolution so the gzip tool's hostname SSRF path can be exercised +// without real network lookups. Defaults to the real implementation; tests +// override per-call with mockResolvedValueOnce. +vi.mock('node:dns/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, lookup: vi.fn(actual.lookup) }; +}); // Helper to capture registered tool handlers function createMockServer() { @@ -1296,5 +1305,64 @@ describe('Tools', () => { fetchSpy.mockRestore(); } }); + + it('should refuse a hostname that resolves to a blocked IP', 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); + + // Hostname (not an IP literal) resolves to the cloud-metadata address. + vi.mocked(lookup).mockResolvedValueOnce([ + { address: '169.254.169.254', family: 4 }, + ] as any); + + await expect( + handler!({ + name: 'test.gz', + data: 'http://metadata.internal.example/', + outputType: 'resource', + }) + ).rejects.toThrow(/SSRF protection/); + }); + + it('should refuse when any of several resolved addresses is blocked', 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); + + // A public and a private address: the "any blocked" rule must reject. + vi.mocked(lookup).mockResolvedValueOnce([ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.5', family: 4 }, + ] as any); + + await expect( + handler!({ + name: 'test.gz', + data: 'http://mixed.example/', + outputType: 'resource', + }) + ).rejects.toThrow(/SSRF protection/); + }); }); }); diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index ccda55d659..382388d9a4 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -452,6 +452,11 @@ async function fetchWithGuardedRedirects( return response; } + // This redirect response is never returned to the caller, so drain its + // body to let undici release the socket instead of leaving it pinned + // across the redirect chain (a leak under load). + await response.body?.cancel(); + // Enforce the limit before following (and re-validating) another hop, so // we never resolve or fetch a redirect target beyond GZIP_MAX_REDIRECTS. if (redirects >= GZIP_MAX_REDIRECTS) { From b8df517f69e481efc972e30935b2d0a22af0bf42 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 19 Jul 2026 21:03:25 -0700 Subject: [PATCH 6/8] fix(everything): wrap DNS lookup failures with host and URL context Addresses Copilot feedback on #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) --- src/everything/tools/gzip-file-as-resource.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index 382388d9a4..289e68991a 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -317,11 +317,20 @@ async function assertPublicHost(url: URL, signal?: AbortSignal): Promise { if (isIP(host)) { addresses = [host]; } else { - const resolved = await withAbort( - lookup(host, { all: true }), - signal, - `Timed out resolving host ${host} for ${url}.` - ); + let resolved; + try { + resolved = await withAbort( + lookup(host, { all: true }), + signal, + "DNS resolution timed out" + ); + } catch (error) { + // Wrap raw Node lookup errors (ENOTFOUND/EAI_AGAIN, or the timeout + // above) so the failure carries host + URL context and matches the + // tool's other error messages. + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not resolve host ${host} for ${url}: ${message}`); + } addresses = resolved.map((r) => r.address); if (addresses.length === 0) { throw new Error(`Could not resolve host ${host} for ${url}`); From a223f12c78a3ef6f5c776e925b0298a361529ae5 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 9 Aug 2026 21:20:45 -0700 Subject: [PATCH 7/8] fix(everything): block IPv6 forms that embed an internal IPv4 address 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 --- src/everything/__tests__/tools.test.ts | 11 ++++ src/everything/tools/gzip-file-as-resource.ts | 53 ++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index bcb441a060..ddf81cf94c 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -1240,6 +1240,17 @@ describe('Tools', () => { ['IPv6 loopback', 'http://[::1]/'], ['IPv4-mapped IPv6 loopback', 'http://[::ffff:127.0.0.1]/'], ['IPv4-compatible IPv6 loopback', 'http://[::127.0.0.1]/'], + ['IPv4-translated IPv6 loopback', 'http://[::ffff:0:7f00:1]/'], + ['NAT64 loopback', 'http://[64:ff9b::7f00:1]/'], + ['NAT64 cloud metadata', 'http://[64:ff9b::a9fe:a9fe]/'], + ['NAT64 local-use prefix', 'http://[64:ff9b:1::1]/'], + ['6to4 loopback', 'http://[2002:7f00:1::]/'], + ['6to4 cloud metadata', 'http://[2002:a9fe:a9fe::]/'], + ['IPv6 unique-local', 'http://[fc00::1]/'], + ['IPv6 link-local', 'http://[fe80::1]/'], + ['IPv6 site-local', 'http://[fec0::1]/'], + ['IPv6 discard-only 100::/64', 'http://[100::1]/'], + ['IPv6 Teredo 2001::/32', 'http://[2001::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 289e68991a..fdaa98c1a5 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -243,30 +243,59 @@ function expandIpv6(addr: string): number[] | null { } /** - * 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. + * Renders the dotted-quad IPv4 address encoded by two 16-bit hextets, used to + * unwrap the IPv6 forms that embed an IPv4 address. + */ +function ipv4FromHextets(hi: number, lo: number): string { + return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; +} + +/** + * Determines whether an IPv6 address string must not be fetched. Addresses that + * embed an IPv4 address (mapped, translated, IPv4-compatible, NAT64, 6to4) are + * unwrapped and classified as IPv4, so an internal destination cannot be reached + * by wrapping it in an IPv6 form that looks globally routable. */ 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); - } + const zeroThrough = (n: number): boolean => g.slice(0, n).every((h) => h === 0); + + // Forms that carry the IPv4 address in the last 32 bits. + const embedsIpv4InSuffix = + // IPv4-mapped (::ffff:a.b.c.d): first 80 bits zero, next 16 bits 0xffff. + (zeroThrough(5) && g[5] === 0xffff) || + // IPv4-translated (::ffff:0:a.b.c.d, ::ffff:0:0/96). + (zeroThrough(4) && g[4] === 0xffff && g[5] === 0) || + // NAT64 well-known prefix (64:ff9b::/96), reachable wherever a NAT64 + // gateway is deployed, e.g. 64:ff9b::169.254.169.254 -> metadata service. + (g[0] === 0x0064 && + g[1] === 0xff9b && + g[2] === 0 && + g[3] === 0 && + g[4] === 0 && + g[5] === 0); + if (embedsIpv4InSuffix) return isBlockedIpv4(ipv4FromHextets(g[6], g[7])); + 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 + if (zeroThrough(7) && 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); - } + if (zeroThrough(6)) return isBlockedIpv4(ipv4FromHextets(g[6], g[7])); + // 6to4 (2002::/16) carries the IPv4 address in bits 16-48 instead. + if (g[0] === 0x2002) return isBlockedIpv4(ipv4FromHextets(g[1], g[2])); + const first = g[0]; + if (first === 0x0064 && g[1] === 0xff9b && g[2] === 0x0001) return true; // 64:ff9b:1::/48 local-use NAT64 + if (first === 0x0100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true; // 100::/64 discard-only + if (first === 0x2001 && (g[1] & 0xfe00) === 0) return true; // 2001::/23 IETF protocol assignments (incl. Teredo) + if (first === 0x2001 && g[1] === 0x0db8) return true; // 2001:db8::/32 documentation + if (first === 0x5f00) return true; // 5f00::/16 SRv6 SIDs if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((first & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local (deprecated, still internal) if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast return false; } From 217da58377af52b05e1b1a82bd46697f361bdf6b Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 12 Aug 2026 19:05:46 -0700 Subject: [PATCH 8/8] fix(everything): treat global unicast as an allow list for IPv6 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 #4498. Co-Authored-By: Claude Opus 5 --- src/everything/__tests__/tools.test.ts | 6 +++++ src/everything/tools/gzip-file-as-resource.ts | 22 ++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index ddf81cf94c..88fcf9c742 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -1251,6 +1251,12 @@ describe('Tools', () => { ['IPv6 site-local', 'http://[fec0::1]/'], ['IPv6 discard-only 100::/64', 'http://[100::1]/'], ['IPv6 Teredo 2001::/32', 'http://[2001::1]/'], + // Everything outside global unicast (2000::/3) is refused, so reserved + // space cannot be reached just because it is not individually listed. + ['IPv6 reserved 4000::/3', 'http://[4000::1]/'], + ['IPv6 reserved 8000::/2', 'http://[8000::1]/'], + ['IPv6 reserved 1000::/4', 'http://[1000::1]/'], + ['IPv6 reserved 0200::/7', 'http://[200::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 fdaa98c1a5..b7235357f6 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -254,7 +254,10 @@ function ipv4FromHextets(hi: number, lo: number): string { * Determines whether an IPv6 address string must not be fetched. Addresses that * embed an IPv4 address (mapped, translated, IPv4-compatible, NAT64, 6to4) are * unwrapped and classified as IPv4, so an internal destination cannot be reached - * by wrapping it in an IPv6 form that looks globally routable. + * by wrapping it in an IPv6 form that looks globally routable. Everything that + * survives unwrapping is then checked against global unicast (2000::/3) as an + * allow list, so an unlisted prefix fails closed rather than being treated as + * public. */ function isBlockedIpv6(ip: string): boolean { const g = expandIpv6(ip.toLowerCase()); @@ -288,15 +291,18 @@ function isBlockedIpv6(ip: string): boolean { if (g[0] === 0x2002) return isBlockedIpv4(ipv4FromHextets(g[1], g[2])); const first = g[0]; - if (first === 0x0064 && g[1] === 0xff9b && g[2] === 0x0001) return true; // 64:ff9b:1::/48 local-use NAT64 - if (first === 0x0100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true; // 100::/64 discard-only + // Only 2000::/3 is assigned as global unicast. Blocking everything else + // covers the ranges that are not publicly routable without having to + // enumerate them - unique-local (fc00::/7), link-local (fe80::/10), + // site-local (fec0::/10), multicast (ff00::/8), discard-only (100::/64), + // local-use NAT64 (64:ff9b:1::/48), SRv6 SIDs (5f00::/16) - and, unlike a + // deny list, fails closed on reserved space such as 4000::/3 and on any + // prefix IANA assigns in future. + if ((first & 0xe000) !== 0x2000) return true; + + // Carve-outs inside global unicast that are still not valid destinations. if (first === 0x2001 && (g[1] & 0xfe00) === 0) return true; // 2001::/23 IETF protocol assignments (incl. Teredo) if (first === 0x2001 && g[1] === 0x0db8) return true; // 2001:db8::/32 documentation - if (first === 0x5f00) return true; // 5f00::/16 SRv6 SIDs - if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local - if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local - if ((first & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local (deprecated, still internal) - if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast return false; }