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

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