From 89d05ebef8344850439ec0d3e4a3ca9783b48a85 Mon Sep 17 00:00:00 2001 From: Ashutosh0x Date: Fri, 7 Aug 2026 12:41:08 +0530 Subject: [PATCH] security: fix SSRF blocklist bypass, Markdown injection in approval prompts, and error reflection Fix 3 security issues in the MCP shared library and Cloudflare gatekeeper: 1. SSRF Blocklist Bypass (endpoint.ts): - normalizeHost() now handles dotted hex/octal IP notation (e.g. 0x7f.0.0.1) - Strips IPv6 zone IDs that could bypass ::1 matching - Handles IPv4-compatible IPv6 addresses ([::127.0.0.1]) 2. Markdown/HTML Injection in Approval Prompts (tools.ts): - quoteUntrusted() now strips HTML tags to prevent injection - Removes Unicode bidirectional control characters (U+200E-200F, U+202A-202E, U+2066-2069, U+061C) to prevent text spoofing - Neutralizes horizontal rules (---/***) and strikethrough (~~) 3. Reflected Error Parameters (cloudflare.ts): - OAuth error callback now sets explicit Content-Type: text/plain to prevent XSS via reflected error/error_description params --- .../gatekeeper-cloudflare/src/cloudflare.ts | 5 ++++- packages/mcp-shared/src/endpoint.ts | 21 +++++++++++++++++++ packages/mcp-shared/src/tools.ts | 7 ++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/gatekeeper-cloudflare/src/cloudflare.ts b/packages/gatekeeper-cloudflare/src/cloudflare.ts index cdfd03dd..b3bc3b14 100644 --- a/packages/gatekeeper-cloudflare/src/cloudflare.ts +++ b/packages/gatekeeper-cloudflare/src/cloudflare.ts @@ -123,7 +123,10 @@ export default { } else if (relPath === "/oauth") { const error = url.searchParams.get("error"); if (error) { - return new Response(`${error}: ${url.searchParams.get("error_description")}`); + // Security fix: Set Content-Type to text/plain to prevent XSS via reflected parameters + return new Response(`${error}: ${url.searchParams.get("error_description")}`, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); } const state = url.searchParams.get("state"); if (!state) return new Response("Error: no 'state' provided"); diff --git a/packages/mcp-shared/src/endpoint.ts b/packages/mcp-shared/src/endpoint.ts index 94b705f7..4fa8e642 100644 --- a/packages/mcp-shared/src/endpoint.ts +++ b/packages/mcp-shared/src/endpoint.ts @@ -33,6 +33,13 @@ const BLOCKED_HOST_PATTERNS = [ // spellings of the same address: `http://2130706433/` and `http://0x7f000001/` are both 127.0.0.1, // and `[::ffff:127.0.0.1]` is its IPv4-mapped IPv6 form. Each becomes dotted-quad. function normalizeHost(hostname: string): string { + // Security fix: Strip IPv6 zone IDs and handle IPv4-compatible IPv6 to prevent SSRF bypass + hostname = hostname.replace(/(%25|%)[^\]]+\]$/, "]"); + const compat = /^\[::([^\]]+)\]$/i.exec(hostname); + if (compat && compat[1].includes(".")) { + hostname = compat[1]; + } + // `URL` rewrites an IPv4-mapped IPv6 address into hex groups, so `[::ffff:127.0.0.1]` arrives as // `[::ffff:7f00:1]` and the dotted-quad spelling is never what we see. const mapped = /^\[::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})\]$/i.exec(hostname); @@ -41,6 +48,20 @@ function normalizeHost(hostname: string): string { return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join("."); } + // Security fix: Parse each octet individually to handle mixed/hex/octal dotted notation + const octets = hostname.split("."); + if (octets.length === 4) { + const parsedOctets = octets.map(octet => { + if (/^0[xX][0-9a-fA-F]+$/.test(octet)) return parseInt(octet, 16); + if (/^0[0-7]+$/.test(octet)) return parseInt(octet, 8); + if (/^(0|[1-9][0-9]*)$/.test(octet)) return parseInt(octet, 10); + return NaN; + }); + if (parsedOctets.every(o => Number.isInteger(o) && o >= 0 && o <= 255)) { + return parsedOctets.join("."); + } + } + // A bare integer (decimal, hex, or octal) is a valid IPv4 address to most resolvers. const asInteger = /^(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)$/.test(hostname) ? Number(hostname) diff --git a/packages/mcp-shared/src/tools.ts b/packages/mcp-shared/src/tools.ts index 6895d185..7143a679 100644 --- a/packages/mcp-shared/src/tools.ts +++ b/packages/mcp-shared/src/tools.ts @@ -159,9 +159,14 @@ function defuseFences(text: string): string { // and headings are neutralized, the text is capped, and the rest is block-quoted. function quoteUntrusted(text: string, max: number): string { const cleaned = defuseFences(text) + // Security fix: strip HTML tags and bidirectional control chars to prevent injection/spoofing + .replace(/<[^>]*>/g, "") + .replace(/[\u200E-\u200F\u202A-\u202E\u2066-\u2069\u061C]/g, "") // Repeated, since one strip leaves `##` as `#` -- still a heading, at heading weight, in the - // prompt the approver reads. + // prompt the approver reads. Also strip horizontal rules and strikethrough. .replace(/^[ \t]*[#>]+[ \t]*/gm, "") + .replace(/^[ \t]*(?:-{3,}|\*{3,})[ \t]*/gm, "") + .replace(/~~/g, "") .trim(); const clipped = cleaned.length > max ? `${cleaned.slice(0, max)}\u2026` : cleaned; return clipped.split("\n").map(line => `> ${line}`).join("\n");