* 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
1 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue