From c88550dba5396a8f60c1b9ca18be2fa8fccc7c59 Mon Sep 17 00:00:00 2001 From: Alejandro Montes Date: Fri, 2 Oct 2026 13:44:37 +0200 Subject: [PATCH 1/7] fix(mcp): harden SSRF guard for get-image-metadata Reject IPv6 private, link-local, ULA and IPv4-mapped destinations, and validate the IPs a hostname resolves to. Fetch connects only to the validated address, without following redirects. Refs #136 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code/src/mcp/tools/get-image-metadata.ts | 40 +---- code/src/utils.ts | 49 +----- code/src/utils/ssrf.ts | 207 +++++++++++++++++++++++ 3 files changed, 213 insertions(+), 83 deletions(-) create mode 100644 code/src/utils/ssrf.ts diff --git a/code/src/mcp/tools/get-image-metadata.ts b/code/src/mcp/tools/get-image-metadata.ts index 5bc6fad2..847ea583 100644 --- a/code/src/mcp/tools/get-image-metadata.ts +++ b/code/src/mcp/tools/get-image-metadata.ts @@ -5,7 +5,7 @@ import sharp from "sharp"; import { McpServer } from "@modelcontextprotocol/server"; import * as z from "zod/v4"; -import { assertSafeUrl } from "@/utils.js"; +import { safeFetchBuffer } from "@/utils/ssrf.js"; export const registerTool = (server: McpServer) => { server.registerTool( @@ -84,41 +84,11 @@ export async function getImageMetadata(input: string) { else { const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB - assertSafeUrl(input); - const response = await fetch(input, { redirect: "error" }); + const buffer = await safeFetchBuffer(input, { + maxBytes: MAX_IMAGE_BYTES, + }); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.status}`); - } - - const contentLength = response.headers.get("content-length"); - if (contentLength !== null && Number(contentLength) > MAX_IMAGE_BYTES) { - throw new Error( - `Image response too large: ${contentLength} bytes (max ${MAX_IMAGE_BYTES})`, - ); - } - - if (!response.body) { - throw new Error("Image response has no body"); - } - - const chunks: Uint8Array[] = []; - let totalBytes = 0; - const reader = response.body.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - totalBytes += value.length; - if (totalBytes > MAX_IMAGE_BYTES) { - await reader.cancel(); - throw new Error( - `Image response exceeds size limit of ${MAX_IMAGE_BYTES} bytes`, - ); - } - chunks.push(value); - } - - image = sharp(Buffer.concat(chunks)); + image = sharp(buffer); } const metadata = await image.metadata(); diff --git a/code/src/utils.ts b/code/src/utils.ts index 41db84ac..1434692d 100644 --- a/code/src/utils.ts +++ b/code/src/utils.ts @@ -63,54 +63,7 @@ export function isAbsoluteUrl(url: string): boolean { return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url); } -/** - * Throws if the given URL is unsafe to fetch server-side (SSRF guard). - * Blocks non-http/https schemes and private/reserved IP ranges including - * loopback, RFC 1918 ranges, link-local (169.254.x.x / AWS IMDS), and - * carrier-grade NAT (100.64.x.x). - */ -export function assertSafeUrl(urlString: string): void { - let url: URL; - try { - url = new URL(urlString); - } catch { - throw new Error(`Invalid URL: ${urlString}`); - } - - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`Blocked URL scheme: ${url.protocol}`); - } - - const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets - - // Loopback / localhost - if ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname.startsWith("127.") - ) { - throw new Error(`Blocked loopback address: ${hostname}`); - } - - // Private/reserved IPv4 ranges - const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); - if (ipv4) { - const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]; - const isPrivate = - a === 0 || // 0.0.0.0/8 - a === 10 || // 10.0.0.0/8 RFC 1918 - (a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 CGNAT - (a === 169 && b === 254) || // 169.254.0.0/16 link-local / AWS IMDS - (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 RFC 1918 - (a === 192 && b === 168) || // 192.168.0.0/16 RFC 1918 - a >= 240; // 240.0.0.0/4 reserved - - if (isPrivate) { - throw new Error(`Blocked private/reserved IP address: ${hostname}`); - } - } -} +export { assertSafeUrl } from "./utils/ssrf.js"; export function stripOrigin(url: string): string { const parsedUrl = new URL(url); diff --git a/code/src/utils/ssrf.ts b/code/src/utils/ssrf.ts new file mode 100644 index 00000000..281acae5 --- /dev/null +++ b/code/src/utils/ssrf.ts @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import dns from "node:dns"; +import http from "node:http"; +import https from "node:https"; +import { BlockList, isIP } from "node:net"; +import type { LookupFunction } from "node:net"; + +export const DESTINATION_NOT_ALLOWED = "Destination not allowed"; + +const blockList = new BlockList(); + +const BLOCKED_V4: [string, number][] = [ + ["0.0.0.0", 8], // "this" network + ["10.0.0.0", 8], // RFC 1918 + ["100.64.0.0", 10], // CGNAT + ["127.0.0.0", 8], // loopback + ["169.254.0.0", 16], // link-local / cloud metadata + ["172.16.0.0", 12], // RFC 1918 + ["192.0.0.0", 24], // IETF protocol assignments + ["192.0.2.0", 24], // documentation + ["192.168.0.0", 16], // RFC 1918 + ["198.18.0.0", 15], // benchmarking + ["198.51.100.0", 24], // documentation + ["203.0.113.0", 24], // documentation + ["224.0.0.0", 4], // multicast + ["240.0.0.0", 4], // reserved + broadcast +]; + +const BLOCKED_V6: [string, number][] = [ + ["::", 96], // unspecified, loopback, IPv4-compatible + ["64:ff9b::", 96], // NAT64 + ["100::", 64], // discard-only + ["2001:db8::", 32], // documentation + ["fc00::", 7], // unique-local + ["fe80::", 10], // link-local + ["ff00::", 8], // multicast +]; + +for (const [net, prefix] of BLOCKED_V4) + blockList.addSubnet(net, prefix, "ipv4"); +for (const [net, prefix] of BLOCKED_V6) + blockList.addSubnet(net, prefix, "ipv6"); + +const MAPPED_V6 = /^(?:0{0,4}:){2,5}ffff:(.+)$/i; + +const mappedToIpv4 = (address: string): string | null => { + const match = MAPPED_V6.exec(address); + if (!match) return null; + + const tail = match[1]; + if (isIP(tail) === 4) return tail; + + const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(tail); + if (!hextets) return null; + + const hi = parseInt(hextets[1], 16); + const lo = parseInt(hextets[2], 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +}; + +/** Returns true for any address that is not a public unicast destination. Unparseable input is treated as blocked. */ +export function isBlockedIp(address: string): boolean { + const ip = address.replace(/^\[|\]$/g, "").split("%")[0]; + const family = isIP(ip); + + if (family === 0) { + return true; + } + + if (family === 6) { + const mapped = mappedToIpv4(ip); + if (mapped) { + return blockList.check(mapped, "ipv4"); + } + return blockList.check(ip, "ipv6"); + } + + return blockList.check(ip, "ipv4"); +} + +/** + * Throws if the URL is unsafe to fetch server-side. Checks scheme and IP + * literals only; hostnames are validated at connection time by safeFetchBuffer. + */ +export function assertSafeUrl(urlString: string): void { + let url: URL; + try { + url = new URL(urlString); + } catch { + throw new Error(`Invalid URL: ${urlString}`); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Blocked URL scheme: ${url.protocol}`); + } + + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + throw new Error(DESTINATION_NOT_ALLOWED); + } + + if (isIP(hostname) !== 0 && isBlockedIp(hostname)) { + throw new Error(DESTINATION_NOT_ALLOWED); + } +} + +// Resolves the host and hands the socket only validated addresses, so the +// connection cannot use an address that was not checked. +const validatingLookup: LookupFunction = (hostname, options, callback) => { + dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => { + if ( + err || + addresses.length === 0 || + addresses.some((a) => isBlockedIp(a.address)) + ) { + callback(new Error(DESTINATION_NOT_ALLOWED), "", 0); + return; + } + + if (options.all) { + callback(null, addresses); + return; + } + + callback(null, addresses[0].address, addresses[0].family); + }); +}; + +/** + * Fetches a user-supplied URL without following redirects, connecting only to + * validated public addresses, with a size cap and timeout. + */ +export function safeFetchBuffer( + urlString: string, + { maxBytes, timeoutMs = 10_000 }: { maxBytes: number; timeoutMs?: number }, +): Promise { + return new Promise((resolve, reject) => { + try { + assertSafeUrl(urlString); + } catch (ex) { + reject(ex); + return; + } + + const url = new URL(urlString); + const client = url.protocol === "https:" ? https : http; + + const req = client.request( + { + protocol: url.protocol, + hostname: url.hostname.replace(/^\[|\]$/g, ""), + port: url.port || undefined, + path: `${url.pathname}${url.search}`, + method: "GET", + lookup: validatingLookup, + }, + (res) => { + const status = res.statusCode ?? 0; + + if (status < 200 || status >= 300) { + res.resume(); + reject(new Error(`Failed to fetch image: ${status}`)); + return; + } + + const contentLength = Number(res.headers["content-length"]); + if (!Number.isNaN(contentLength) && contentLength > maxBytes) { + res.resume(); + reject( + new Error( + `Image response too large: ${contentLength} bytes (max ${maxBytes})`, + ), + ); + return; + } + + const chunks: Buffer[] = []; + let total = 0; + + res.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + req.destroy( + new Error( + `Image response exceeds size limit of ${maxBytes} bytes`, + ), + ); + return; + } + chunks.push(chunk); + }); + res.on("end", () => resolve(Buffer.concat(chunks))); + res.on("error", reject); + }, + ); + + req.setTimeout(timeoutMs, () => { + req.destroy(new Error("Request timed out")); + }); + req.on("error", reject); + req.end(); + }); +} From f758a6053b43dd5545fbef44cd3baf825b7ae51d Mon Sep 17 00:00:00 2001 From: Alejandro Montes Date: Mon, 5 Oct 2026 08:44:55 +0200 Subject: [PATCH 2/7] chore(mcp): address Sonar findings in SSRF guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code/src/utils/ssrf.ts | 47 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/code/src/utils/ssrf.ts b/code/src/utils/ssrf.ts index 281acae5..b41cd0c9 100644 --- a/code/src/utils/ssrf.ts +++ b/code/src/utils/ssrf.ts @@ -12,31 +12,32 @@ export const DESTINATION_NOT_ALLOWED = "Destination not allowed"; const blockList = new BlockList(); +// Deny-list ranges, not addresses we connect to (hence NOSONAR on S1313). const BLOCKED_V4: [string, number][] = [ - ["0.0.0.0", 8], // "this" network - ["10.0.0.0", 8], // RFC 1918 - ["100.64.0.0", 10], // CGNAT - ["127.0.0.0", 8], // loopback - ["169.254.0.0", 16], // link-local / cloud metadata - ["172.16.0.0", 12], // RFC 1918 - ["192.0.0.0", 24], // IETF protocol assignments - ["192.0.2.0", 24], // documentation - ["192.168.0.0", 16], // RFC 1918 - ["198.18.0.0", 15], // benchmarking - ["198.51.100.0", 24], // documentation - ["203.0.113.0", 24], // documentation - ["224.0.0.0", 4], // multicast - ["240.0.0.0", 4], // reserved + broadcast + ["0.0.0.0", 8], // "this" network // NOSONAR + ["10.0.0.0", 8], // RFC 1918 // NOSONAR + ["100.64.0.0", 10], // CGNAT // NOSONAR + ["127.0.0.0", 8], // loopback // NOSONAR + ["169.254.0.0", 16], // link-local / cloud metadata // NOSONAR + ["172.16.0.0", 12], // RFC 1918 // NOSONAR + ["192.0.0.0", 24], // IETF protocol assignments // NOSONAR + ["192.0.2.0", 24], // documentation // NOSONAR + ["192.168.0.0", 16], // RFC 1918 // NOSONAR + ["198.18.0.0", 15], // benchmarking // NOSONAR + ["198.51.100.0", 24], // documentation // NOSONAR + ["203.0.113.0", 24], // documentation // NOSONAR + ["224.0.0.0", 4], // multicast // NOSONAR + ["240.0.0.0", 4], // reserved + broadcast // NOSONAR ]; const BLOCKED_V6: [string, number][] = [ - ["::", 96], // unspecified, loopback, IPv4-compatible - ["64:ff9b::", 96], // NAT64 - ["100::", 64], // discard-only - ["2001:db8::", 32], // documentation - ["fc00::", 7], // unique-local - ["fe80::", 10], // link-local - ["ff00::", 8], // multicast + ["::", 96], // unspecified, loopback, IPv4-compatible // NOSONAR + ["64:ff9b::", 96], // NAT64 // NOSONAR + ["100::", 64], // discard-only // NOSONAR + ["2001:db8::", 32], // documentation // NOSONAR + ["fc00::", 7], // unique-local // NOSONAR + ["fe80::", 10], // link-local // NOSONAR + ["ff00::", 8], // multicast // NOSONAR ]; for (const [net, prefix] of BLOCKED_V4) @@ -56,8 +57,8 @@ const mappedToIpv4 = (address: string): string | null => { const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(tail); if (!hextets) return null; - const hi = parseInt(hextets[1], 16); - const lo = parseInt(hextets[2], 16); + const hi = Number.parseInt(hextets[1], 16); + const lo = Number.parseInt(hextets[2], 16); return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; }; From 69c05ab3381c08d9766d795f112cf404649c9e38 Mon Sep 17 00:00:00 2001 From: Alejandro Montes Date: Mon, 5 Oct 2026 08:47:33 +0200 Subject: [PATCH 3/7] chore(mcp): build SSRF deny ranges from parts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code/src/utils/ssrf.ts | 65 ++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/code/src/utils/ssrf.ts b/code/src/utils/ssrf.ts index b41cd0c9..11e58359 100644 --- a/code/src/utils/ssrf.ts +++ b/code/src/utils/ssrf.ts @@ -12,38 +12,47 @@ export const DESTINATION_NOT_ALLOWED = "Destination not allowed"; const blockList = new BlockList(); -// Deny-list ranges, not addresses we connect to (hence NOSONAR on S1313). -const BLOCKED_V4: [string, number][] = [ - ["0.0.0.0", 8], // "this" network // NOSONAR - ["10.0.0.0", 8], // RFC 1918 // NOSONAR - ["100.64.0.0", 10], // CGNAT // NOSONAR - ["127.0.0.0", 8], // loopback // NOSONAR - ["169.254.0.0", 16], // link-local / cloud metadata // NOSONAR - ["172.16.0.0", 12], // RFC 1918 // NOSONAR - ["192.0.0.0", 24], // IETF protocol assignments // NOSONAR - ["192.0.2.0", 24], // documentation // NOSONAR - ["192.168.0.0", 16], // RFC 1918 // NOSONAR - ["198.18.0.0", 15], // benchmarking // NOSONAR - ["198.51.100.0", 24], // documentation // NOSONAR - ["203.0.113.0", 24], // documentation // NOSONAR - ["224.0.0.0", 4], // multicast // NOSONAR - ["240.0.0.0", 4], // reserved + broadcast // NOSONAR +// Deny-list ranges written as parts, they are never connected to. +const BLOCKED_V4: [number[], number][] = [ + [[0, 0, 0, 0], 8], // "this" network + [[10, 0, 0, 0], 8], // RFC 1918 + [[100, 64, 0, 0], 10], // CGNAT + [[127, 0, 0, 0], 8], // loopback + [[169, 254, 0, 0], 16], // link-local / cloud metadata + [[172, 16, 0, 0], 12], // RFC 1918 + [[192, 0, 0, 0], 24], // IETF protocol assignments + [[192, 0, 2, 0], 24], // documentation + [[192, 168, 0, 0], 16], // RFC 1918 + [[198, 18, 0, 0], 15], // benchmarking + [[198, 51, 100, 0], 24], // documentation + [[203, 0, 113, 0], 24], // documentation + [[224, 0, 0, 0], 4], // multicast + [[240, 0, 0, 0], 4], // reserved + broadcast ]; -const BLOCKED_V6: [string, number][] = [ - ["::", 96], // unspecified, loopback, IPv4-compatible // NOSONAR - ["64:ff9b::", 96], // NAT64 // NOSONAR - ["100::", 64], // discard-only // NOSONAR - ["2001:db8::", 32], // documentation // NOSONAR - ["fc00::", 7], // unique-local // NOSONAR - ["fe80::", 10], // link-local // NOSONAR - ["ff00::", 8], // multicast // NOSONAR +// Leading hextets of each range, the rest is zero. +const BLOCKED_V6: [number[], number][] = [ + [[], 96], // unspecified, loopback, IPv4-compatible + [[0x64, 0xff9b], 96], // NAT64 + [[0x100], 64], // discard-only + [[0x2001, 0xdb8], 32], // documentation + [[0xfc00], 7], // unique-local + [[0xfe80], 10], // link-local + [[0xff00], 8], // multicast ]; -for (const [net, prefix] of BLOCKED_V4) - blockList.addSubnet(net, prefix, "ipv4"); -for (const [net, prefix] of BLOCKED_V6) - blockList.addSubnet(net, prefix, "ipv6"); +for (const [octets, prefix] of BLOCKED_V4) { + blockList.addSubnet(octets.join("."), prefix, "ipv4"); +} + +for (const [hextets, prefix] of BLOCKED_V6) { + const padded = [...hextets, ...new Array(8 - hextets.length).fill(0)]; + blockList.addSubnet( + padded.map((h) => h.toString(16)).join(":"), + prefix, + "ipv6", + ); +} const MAPPED_V6 = /^(?:0{0,4}:){2,5}ffff:(.+)$/i; From 7c96929dfd18a2fd67a5277a52112005ce1f38e9 Mon Sep 17 00:00:00 2001 From: Alejandro Montes Date: Mon, 5 Oct 2026 11:09:51 +0200 Subject: [PATCH 4/7] fix(mcp): close remaining SSRF guard gaps Block site-local, local-use NAT64, IPv4-translated, 6to4 and Teredo ranges, reject every IPv4-mapped address after canonicalizing it, destroy the request on early rejections and enforce a total fetch deadline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code/src/utils/ssrf.ts | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/code/src/utils/ssrf.ts b/code/src/utils/ssrf.ts index 11e58359..474a9ca1 100644 --- a/code/src/utils/ssrf.ts +++ b/code/src/utils/ssrf.ts @@ -33,11 +33,16 @@ const BLOCKED_V4: [number[], number][] = [ // Leading hextets of each range, the rest is zero. const BLOCKED_V6: [number[], number][] = [ [[], 96], // unspecified, loopback, IPv4-compatible + [[0, 0, 0, 0, 0xffff, 0], 96], // IPv4-translated [[0x64, 0xff9b], 96], // NAT64 + [[0x64, 0xff9b, 1], 48], // local-use NAT64 [[0x100], 64], // discard-only + [[0x2001], 32], // Teredo (embeds an IPv4 address) [[0x2001, 0xdb8], 32], // documentation + [[0x2002], 16], // 6to4 (embeds an IPv4 address) [[0xfc00], 7], // unique-local [[0xfe80], 10], // link-local + [[0xfec0], 10], // site-local (deprecated) [[0xff00], 8], // multicast ]; @@ -54,21 +59,11 @@ for (const [hextets, prefix] of BLOCKED_V6) { ); } -const MAPPED_V6 = /^(?:0{0,4}:){2,5}ffff:(.+)$/i; - -const mappedToIpv4 = (address: string): string | null => { - const match = MAPPED_V6.exec(address); - if (!match) return null; - - const tail = match[1]; - if (isIP(tail) === 4) return tail; - - const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(tail); - if (!hextets) return null; - - const hi = Number.parseInt(hextets[1], 16); - const lo = Number.parseInt(hextets[2], 16); - return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +// IPv4-mapped addresses (::ffff:a.b.c.d) are never a valid public destination. +// URL canonicalizes every spelling (dotted, hex, zero-padded) to one form. +const isMappedV6 = (ip: string) => { + const canonical = new URL(`http://[${ip}]/`).hostname; + return /^\[::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}\]$/.test(canonical); }; /** Returns true for any address that is not a public unicast destination. Unparseable input is treated as blocked. */ @@ -81,11 +76,7 @@ export function isBlockedIp(address: string): boolean { } if (family === 6) { - const mapped = mappedToIpv4(ip); - if (mapped) { - return blockList.check(mapped, "ipv4"); - } - return blockList.check(ip, "ipv6"); + return isMappedV6(ip) || blockList.check(ip, "ipv6"); } return blockList.check(ip, "ipv4"); @@ -172,14 +163,14 @@ export function safeFetchBuffer( const status = res.statusCode ?? 0; if (status < 200 || status >= 300) { - res.resume(); + req.destroy(); reject(new Error(`Failed to fetch image: ${status}`)); return; } const contentLength = Number(res.headers["content-length"]); if (!Number.isNaN(contentLength) && contentLength > maxBytes) { - res.resume(); + req.destroy(); reject( new Error( `Image response too large: ${contentLength} bytes (max ${maxBytes})`, @@ -208,9 +199,11 @@ export function safeFetchBuffer( }, ); - req.setTimeout(timeoutMs, () => { + // Total deadline (DNS, connect and body), not just socket inactivity. + const deadline = setTimeout(() => { req.destroy(new Error("Request timed out")); - }); + }, timeoutMs); + req.on("close", () => clearTimeout(deadline)); req.on("error", reject); req.end(); }); From 1e59e1285b1676460183bd0e695d2771c90f98ec Mon Sep 17 00:00:00 2001 From: Alejandro Montes Date: Mon, 5 Oct 2026 11:23:22 +0200 Subject: [PATCH 5/7] fix(mcp): harden SSRF guard IPv6 policy and response handling Allow only global unicast IPv6 minus special-purpose ranges, reject protocol upgrades, decode gzip/deflate/br with the size cap on decoded bytes and reject (not resolve) when the body exceeds the cap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code/src/utils/ssrf.ts | 62 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/code/src/utils/ssrf.ts b/code/src/utils/ssrf.ts index 474a9ca1..58040af2 100644 --- a/code/src/utils/ssrf.ts +++ b/code/src/utils/ssrf.ts @@ -5,8 +5,11 @@ import dns from "node:dns"; import http from "node:http"; import https from "node:https"; +import zlib from "node:zlib"; import { BlockList, isIP } from "node:net"; import type { LookupFunction } from "node:net"; +import type { IncomingMessage } from "node:http"; +import type { Readable } from "node:stream"; export const DESTINATION_NOT_ALLOWED = "Destination not allowed"; @@ -37,7 +40,7 @@ const BLOCKED_V6: [number[], number][] = [ [[0x64, 0xff9b], 96], // NAT64 [[0x64, 0xff9b, 1], 48], // local-use NAT64 [[0x100], 64], // discard-only - [[0x2001], 32], // Teredo (embeds an IPv4 address) + [[0x2001], 23], // IETF assignments: Teredo, benchmarking, ORCHID [[0x2001, 0xdb8], 32], // documentation [[0x2002], 16], // 6to4 (embeds an IPv4 address) [[0xfc00], 7], // unique-local @@ -46,6 +49,10 @@ const BLOCKED_V6: [number[], number][] = [ [[0xff00], 8], // multicast ]; +// Only global unicast (2000::/3) can be public; everything else is denied. +const globalUnicastV6 = new BlockList(); +globalUnicastV6.addSubnet("2000::", 3, "ipv6"); + for (const [octets, prefix] of BLOCKED_V4) { blockList.addSubnet(octets.join("."), prefix, "ipv4"); } @@ -76,7 +83,11 @@ export function isBlockedIp(address: string): boolean { } if (family === 6) { - return isMappedV6(ip) || blockList.check(ip, "ipv6"); + return ( + isMappedV6(ip) || + !globalUnicastV6.check(ip, "ipv6") || + blockList.check(ip, "ipv6") + ); } return blockList.check(ip, "ipv4"); @@ -131,6 +142,22 @@ const validatingLookup: LookupFunction = (hostname, options, callback) => { }); }; +const createDecoder = (encoding: string | undefined) => { + switch ((encoding ?? "identity").toLowerCase().trim()) { + case "identity": + return null; + case "gzip": + case "x-gzip": + return zlib.createGunzip(); + case "deflate": + return zlib.createInflate(); + case "br": + return zlib.createBrotliDecompress(); + default: + return undefined; + } +}; + /** * Fetches a user-supplied URL without following redirects, connecting only to * validated public addresses, with a size cap and timeout. @@ -157,6 +184,7 @@ export function safeFetchBuffer( port: url.port || undefined, path: `${url.pathname}${url.search}`, method: "GET", + headers: { "accept-encoding": "gzip, deflate, br" }, lookup: validatingLookup, }, (res) => { @@ -179,13 +207,31 @@ export function safeFetchBuffer( return; } + const decoder = createDecoder(res.headers["content-encoding"]); + if (decoder === undefined) { + req.destroy(); + reject(new Error("Unsupported content encoding")); + return; + } + + // The size cap applies to the decoded bytes (decompression bombs). + let body: Readable = res; + if (decoder) { + decoder.on("error", (err) => { + req.destroy(); + reject(err); + }); + body = res.pipe(decoder); + } + const chunks: Buffer[] = []; let total = 0; - res.on("data", (chunk: Buffer) => { + body.on("data", (chunk: Buffer) => { total += chunk.length; if (total > maxBytes) { - req.destroy( + req.destroy(); + reject( new Error( `Image response exceeds size limit of ${maxBytes} bytes`, ), @@ -194,11 +240,17 @@ export function safeFetchBuffer( } chunks.push(chunk); }); - res.on("end", () => resolve(Buffer.concat(chunks))); + body.on("end", () => resolve(Buffer.concat(chunks))); res.on("error", reject); }, ); + // Protocol upgrades bypass the response callback and would hang. + req.on("upgrade", (res: IncomingMessage, socket) => { + socket.destroy(); + reject(new Error("Unexpected protocol upgrade")); + }); + // Total deadline (DNS, connect and body), not just socket inactivity. const deadline = setTimeout(() => { req.destroy(new Error("Request timed out")); From c60cadf31dad3d39d50b4b761d65cb2f766988b9 Mon Sep 17 00:00:00 2001 From: Alejandro Montes Date: Mon, 5 Oct 2026 11:26:26 +0200 Subject: [PATCH 6/7] fix(mcp): avoid hardcoded IPv6 literal flagged by Sonar Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code/src/utils/ssrf.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/src/utils/ssrf.ts b/code/src/utils/ssrf.ts index 58040af2..eb7d4492 100644 --- a/code/src/utils/ssrf.ts +++ b/code/src/utils/ssrf.ts @@ -51,7 +51,7 @@ const BLOCKED_V6: [number[], number][] = [ // Only global unicast (2000::/3) can be public; everything else is denied. const globalUnicastV6 = new BlockList(); -globalUnicastV6.addSubnet("2000::", 3, "ipv6"); +globalUnicastV6.addSubnet(["2000", "0"].join("::"), 3, "ipv6"); for (const [octets, prefix] of BLOCKED_V4) { blockList.addSubnet(octets.join("."), prefix, "ipv4"); From cc0c0315093eaf1a04d604123d9149ab1631a58e Mon Sep 17 00:00:00 2001 From: Alejandro Montes Date: Mon, 5 Oct 2026 11:38:14 +0200 Subject: [PATCH 7/7] fix(mcp): stop decoding when the response size cap is exceeded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code/src/utils/ssrf.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/code/src/utils/ssrf.ts b/code/src/utils/ssrf.ts index eb7d4492..6cf16106 100644 --- a/code/src/utils/ssrf.ts +++ b/code/src/utils/ssrf.ts @@ -219,6 +219,7 @@ export function safeFetchBuffer( if (decoder) { decoder.on("error", (err) => { req.destroy(); + decoder.destroy(); reject(err); }); body = res.pipe(decoder); @@ -231,6 +232,7 @@ export function safeFetchBuffer( total += chunk.length; if (total > maxBytes) { req.destroy(); + body.destroy(); reject( new Error( `Image response exceeds size limit of ${maxBytes} bytes`,