* 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:
parent
c146313bb3
commit
d40c1c94e7
2 changed files with 229 additions and 5 deletions
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> = {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue