diff --git a/client/src/__tests__/proxyFetchEndpoint.test.ts b/client/src/__tests__/proxyFetchEndpoint.test.ts index 4be75f6c4..7084cb6e2 100644 --- a/client/src/__tests__/proxyFetchEndpoint.test.ts +++ b/client/src/__tests__/proxyFetchEndpoint.test.ts @@ -231,4 +231,97 @@ describe("POST /fetch endpoint", () => { }, ); }); + + it("returns 403 for a link-local / cloud-metadata target (SSRF guard)", async () => { + const res = await fetch(`${baseUrl}/fetch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, + }, + body: JSON.stringify({ + url: "http://169.254.169.254/latest/meta-data/", + init: { method: "GET" }, + }), + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/blocked address/i); + }); + + it("returns 403 for the metadata IP in IPv4-mapped IPv6 form (SSRF guard)", async () => { + // The WHATWG URL parser serializes this host to ::ffff:a9fe:a9fe, which must + // still be recognized as 169.254.169.254 and blocked. + const res = await fetch(`${baseUrl}/fetch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, + }, + body: JSON.stringify({ + url: "http://[::ffff:169.254.169.254]/latest/meta-data/", + init: { method: "GET" }, + }), + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/blocked address/i); + }); + + it("blocks a redirect that points at a blocked address (SSRF guard)", async () => { + await withLocalUpstream( + (req, res) => { + res.writeHead(302, { Location: "http://169.254.169.254/latest/" }); + res.end(); + }, + async (origin) => { + const res = await fetch(`${baseUrl}/fetch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, + }, + body: JSON.stringify({ + url: `${origin}/redirect`, + init: { method: "GET" }, + }), + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/blocked address/i); + }, + ); + }); + + it("follows redirects between allowed hosts", async () => { + const payload = JSON.stringify({ hello: "redirected" }); + await withLocalUpstream( + (req, res) => { + if (req.url === "/start") { + res.writeHead(302, { Location: "/final" }); + res.end(); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(payload); + }, + async (origin) => { + const res = await fetch(`${baseUrl}/fetch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, + }, + body: JSON.stringify({ + url: `${origin}/start`, + init: { method: "GET" }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: number; body: string }; + expect(body.status).toBe(200); + expect(body.body).toBe(payload); + }, + ); + }); }); diff --git a/server/src/index.ts b/server/src/index.ts index bdfe49019..4c86bd791 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -29,6 +29,8 @@ import rateLimit from "express-rate-limit"; import { findActualExecutable } from "spawn-rx"; import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js"; import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; +import { lookup as dnsLookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { readFileSync } from "fs"; @@ -861,6 +863,130 @@ app.get("/health", (req, res) => { }); }); +// Raised when the /fetch proxy is asked to reach a disallowed target, so the +// route can distinguish an SSRF rejection (403) from an upstream failure (500). +class ProxyTargetError extends Error {} + +const MAX_PROXY_REDIRECTS = 5; + +// The /fetch proxy is used by the client to reach OAuth discovery/token +// endpoints on the connected MCP server (bypassing browser CORS). Connecting +// to loopback and RFC1918/private LAN hosts is a core use case (testing local +// or self-hosted MCP servers), so those stay reachable. We only block +// link-local addresses — 169.254.0.0/16 (the AWS/GCP/Azure/etc. cloud metadata +// endpoint 169.254.169.254 lives here) and IPv6 link-local / the AWS IPv6 IMDS +// address — which are never legitimate proxy targets and are the real SSRF +// exfiltration vector. +function isBlockedProxyAddress(ip: string): boolean { + const kind = isIP(ip); + if (kind === 4) { + const parts = ip.split(".").map(Number); + // 169.254.0.0/16 — IPv4 link-local (incl. cloud metadata 169.254.169.254) + return parts[0] === 169 && parts[1] === 254; + } + if (kind === 6) { + const addr = ip.toLowerCase(); + // IPv4-mapped IPv6 (::ffff:x). The WHATWG URL parser serializes the embedded + // IPv4 in hex (::ffff:a9fe:a9fe for 169.254.169.254), while DNS and other + // sources may use the dotted form (::ffff:169.254.169.254) — handle both by + // extracting the IPv4 and re-checking it. + const mapped = addr.match(/^::ffff:(.+)$/); + if (mapped) { + const tail = mapped[1]; + if (isIP(tail) === 4) { + return isBlockedProxyAddress(tail); + } + const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const high = parseInt(hex[1], 16); + const low = parseInt(hex[2], 16); + return isBlockedProxyAddress( + `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`, + ); + } + } + // fe80::/10 — IPv6 link-local + if (/^fe[89ab]/.test(addr)) { + return true; + } + // fd00:ec2::254 — AWS IPv6 instance metadata endpoint + return addr === "fd00:ec2::254"; + } + return false; +} + +// Resolves the target host and rejects it if any resolved address is a blocked +// (link-local/metadata) address, mitigating SSRF including the DNS case where a +// hostname resolves to the cloud metadata IP. +async function assertSafeProxyTarget(parsedUrl: URL): Promise { + const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets + + let addresses: string[]; + if (isIP(host)) { + addresses = [host]; + } else { + try { + addresses = (await dnsLookup(host, { all: true })).map((r) => r.address); + } catch { + throw new ProxyTargetError(`Could not resolve host: ${host}`); + } + } + + if (addresses.length === 0) { + throw new ProxyTargetError(`Could not resolve host: ${host}`); + } + + for (const address of addresses) { + if (isBlockedProxyAddress(address)) { + throw new ProxyTargetError( + `Refusing to proxy request to blocked address (${address})`, + ); + } + } +} + +// Fetch that follows redirects manually so every hop's target is re-validated +// against assertSafeProxyTarget — otherwise an allowed host could redirect the +// proxy into a blocked internal/metadata address. +async function safeProxyFetch(initialUrl: string, init?: RequestInit) { + let currentUrl = new URL(initialUrl); + let method = init?.method ?? "GET"; + let body = init?.body as string | undefined; + const headers = (init?.headers as Record) ?? {}; + + for (let hop = 0; hop <= MAX_PROXY_REDIRECTS; hop++) { + if (!["http:", "https:"].includes(currentUrl.protocol)) { + throw new ProxyTargetError("Only http/https URLs are allowed"); + } + await assertSafeProxyTarget(currentUrl); + + const response = await fetch(currentUrl.toString(), { + method, + headers, + body, + redirect: "manual", + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) { + return response; + } + currentUrl = new URL(location, currentUrl); + // Mirror browser/fetch semantics: 301/302/303 downgrade to a bodyless GET. + if ([301, 302, 303].includes(response.status)) { + method = "GET"; + body = undefined; + } + continue; + } + + return response; + } + + throw new ProxyTargetError("Too many redirects"); +} + app.post( "/fetch", express.json(), @@ -888,11 +1014,16 @@ app.post( return; } - const response = await fetch(url, { - method: init?.method ?? "GET", - headers: (init?.headers as Record) ?? {}, - body: init?.body as string | undefined, - }); + let response: Awaited>; + try { + response = await safeProxyFetch(url, init); + } catch (error) { + if (error instanceof ProxyTargetError) { + res.status(403).json({ error: error.message }); + return; + } + throw error; + } const responseBody = await response.text(); const headers: Record = {};