Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions client/src/__tests__/proxyFetchEndpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);
});
});
141 changes: 136 additions & 5 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
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";
Expand Down Expand Up @@ -861,6 +863,130 @@
});
});

// 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<void> {
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<string, string>) ?? {};

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",
});
Comment thread
cliffhall marked this conversation as resolved.
Dismissed

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(),
Expand Down Expand Up @@ -888,11 +1014,16 @@
return;
}

const response = await fetch(url, {
method: init?.method ?? "GET",
headers: (init?.headers as Record<string, string>) ?? {},
body: init?.body as string | undefined,
});
let response: Awaited<ReturnType<typeof safeProxyFetch>>;
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<string, string> = {};
Expand Down
Loading