* 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 { findActualExecutable } from "spawn-rx";
|
||||||
import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js";
|
import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js";
|
||||||
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
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 { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { readFileSync } from "fs";
|
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(
|
app.post(
|
||||||
"/fetch",
|
"/fetch",
|
||||||
express.json(),
|
express.json(),
|
||||||
|
|
@ -888,11 +1014,16 @@ app.post(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let response: Awaited<ReturnType<typeof safeProxyFetch>>;
|
||||||
method: init?.method ?? "GET",
|
try {
|
||||||
headers: (init?.headers as Record<string, string>) ?? {},
|
response = await safeProxyFetch(url, init);
|
||||||
body: init?.body as string | undefined,
|
} catch (error) {
|
||||||
});
|
if (error instanceof ProxyTargetError) {
|
||||||
|
res.status(403).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const responseBody = await response.text();
|
const responseBody = await response.text();
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue