fix(server): guard the /fetch proxy against SSRF to link-local/metadata (#53) (#1717)

* fix(server): guard the /fetch proxy against SSRF to link-local/metadata

The /fetch proxy forwarded any user-supplied http/https URL straight into
fetch(), so a malicious remote MCP server could advertise OAuth metadata
pointing at the cloud metadata endpoint (169.254.169.254) and have the proxy
exfiltrate instance credentials (CodeQL js/request-forgery, alert #53).

Add a targeted SSRF guard: resolve the target host and reject link-local /
cloud-metadata addresses (169.254.0.0/16, IPv6 link-local, AWS IPv6 IMDS).
Loopback and RFC1918/private LAN targets stay reachable, since connecting to
local and self-hosted MCP servers is a core Inspector use case. Redirects are
now followed manually so every hop is re-validated, closing the
redirect-to-internal bypass. Returns 403 on a blocked target.

Adds endpoint tests for a direct metadata target, a redirect into a blocked
address, and normal redirects between allowed hosts.

Resolves code-scanning alert #53. Part of #1706.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HiccCEh9mwCfVzE8qYcop1

* fix(server): block IPv4-mapped IPv6 form of metadata IP in /fetch guard

The IPv4-mapped IPv6 branch of isBlockedProxyAddress only matched the
dotted-decimal form (::ffff:169.254.169.254), but the WHATWG URL parser
serializes IPv4-mapped addresses in hex (::ffff:a9fe:a9fe), so
http://[::ffff:169.254.169.254]/ resolved past the guard and still reached
the cloud metadata service on dual-stack hosts — the exact vector this guard
targets.

Extract the embedded IPv4 from both the hex and dotted forms and re-check it.
Adds a regression test for the IPv4-mapped IPv6 metadata address.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HiccCEh9mwCfVzE8qYcop1

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-17 20:43:19 -04:00 • committed by GitHub
commit d40c1c94e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 229 additions and 5 deletions

View file

@ -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);
},
);
});
});

View file

@ -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<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",
});
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<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> = {};