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");