diff --git a/backend/package-lock.json b/backend/package-lock.json index 7bed71892..e997f52d0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -29,6 +29,7 @@ "multer": "^1.4.5-lts.2", "pdfjs-dist": "^4.10.38", "resend": "^4.5.1", + "undici": "^6.27.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.25.76" }, @@ -6954,6 +6955,15 @@ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0223d9a1a..95cd7773c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "multer": "^1.4.5-lts.2", "pdfjs-dist": "^4.10.38", "resend": "^4.5.1", + "undici": "^6.27.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.25.76" }, diff --git a/backend/src/lib/__tests__/privateIp.test.ts b/backend/src/lib/__tests__/privateIp.test.ts new file mode 100644 index 000000000..c626cabb0 --- /dev/null +++ b/backend/src/lib/__tests__/privateIp.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { isBlockedIp, isPrivateIpv4, isPrivateIpv6 } from "../privateIp"; + +describe("private/reserved IP classification", () => { + it.each([ + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "192.0.0.1", + "192.0.2.1", + "192.88.99.1", + "192.168.0.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + ])("blocks non-global IPv4 address %s", (ip) => { + expect(isPrivateIpv4(ip)).toBe(true); + expect(isBlockedIp(ip)).toBe(true); + }); + + it.each(["8.8.8.8", "93.184.216.34", "192.31.196.1"])( + "allows globally reachable IPv4 address %s", + (ip) => { + expect(isPrivateIpv4(ip)).toBe(false); + expect(isBlockedIp(ip)).toBe(false); + }, + ); + + it.each([ + "::", + "::1", + "::ffff:8.8.8.8", + "::8.8.8.8", + "100::1", + "2001::1", + "2001:2::1", + "2001:10::1", + "2001:db8::1", + "2002::1", + "3fff::1", + "5f00::1", + "fc00::1", + "fd00::1", + "fe80::1", + "fec0::1", + "ff02::1", + "64:ff9b::10.0.0.1", + "64:ff9b:1::1", + ])("blocks non-global IPv6 address %s", (ip) => { + expect(isPrivateIpv6(ip)).toBe(true); + expect(isBlockedIp(ip)).toBe(true); + }); + + it.each([ + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "64:ff9b::8.8.8.8", + ])("allows globally reachable IPv6 address %s", (ip) => { + expect(isPrivateIpv6(ip)).toBe(false); + expect(isBlockedIp(ip)).toBe(false); + }); + + it.each(["1foo.2.3.4", "999.1.1.1", "not-an-ip"])( + "fails closed for malformed address %s", + (ip) => { + expect(isPrivateIpv4(ip)).toBe(true); + expect(isBlockedIp(ip)).toBe(true); + }, + ); +}); diff --git a/backend/src/lib/mcp/__tests__/client.ssrf.test.ts b/backend/src/lib/mcp/__tests__/client.ssrf.test.ts new file mode 100644 index 000000000..454f274a8 --- /dev/null +++ b/backend/src/lib/mcp/__tests__/client.ssrf.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Agent } from "undici"; + +// Mock DNS resolution so the SSRF guard is exercised deterministically without +// touching the network. `lookupMock` is hoisted so the vi.mock factory can +// reference it. +const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() })); +vi.mock("dns/promises", () => ({ + default: { lookup: lookupMock }, +})); + +import { guardedFetch, validateRemoteMcpUrl } from "../client"; + +function resolvesTo(...addresses: string[]) { + lookupMock.mockResolvedValue( + addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })), + ); +} + +beforeEach(() => { + lookupMock.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("validateRemoteMcpUrl", () => { + it("rejects non-HTTPS URLs", async () => { + await expect(validateRemoteMcpUrl("http://example.com/")).rejects.toThrow( + /HTTPS/, + ); + }); + + it("rejects invalid URLs", async () => { + await expect(validateRemoteMcpUrl("not a url")).rejects.toThrow( + /valid URL/, + ); + }); + + it("rejects localhost and metadata hosts without a DNS lookup", async () => { + for (const host of [ + "https://localhost/", + "https://foo.localhost/", + "https://metadata.google.internal/", + "https://instance-data/", + ]) { + await expect(validateRemoteMcpUrl(host), host).rejects.toThrow( + /blocked host/, + ); + } + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it("rejects private IPv4/IPv6 literals without a DNS lookup", async () => { + for (const host of [ + "https://127.0.0.1/", + "https://10.0.0.1/", + "https://169.254.169.254/", + "https://[::1]/", + "https://[fd00::1]/", + "https://[fec0::1]/", + "https://[ff02::1]/", + "https://[100::1]/", + "https://[2001:db8::1]/", + "https://[3fff::1]/", + "https://[64:ff9b:1::1]/", + // IPv4-compatible ::/96 embeds (deprecated per RFC 4291 but still + // parseable) — `::127.0.0.1` in hex, uncompressed, and dotted forms + "https://[::7f00:1]/", + "https://[0:0:0:0:0:0:7f00:1]/", + "https://[::10.0.0.1]/", + ]) { + await expect(validateRemoteMcpUrl(host), host).rejects.toThrow( + /blocked network address/, + ); + } + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it("rejects a hostname that resolves to a private address", async () => { + resolvesTo("10.0.0.5"); + await expect( + validateRemoteMcpUrl("https://rebind.example.com/"), + ).rejects.toThrow(/blocked network address/); + }); + + it("rejects when ANY resolved address is private (mixed record set)", async () => { + resolvesTo("93.184.216.34", "192.168.1.1"); + await expect( + validateRemoteMcpUrl("https://mixed.example.com/"), + ).rejects.toThrow(/blocked network address/); + }); + + it("accepts a public host and strips credentials/hash", async () => { + resolvesTo("93.184.216.34"); + const out = await validateRemoteMcpUrl( + "https://user:secret@public.example.com/path?q=1#frag", + ); + expect(out).toBe("https://public.example.com/path?q=1"); + expect(out).not.toContain("secret"); + expect(out).not.toContain("frag"); + }); +}); + +describe("guardedFetch", () => { + it("throws and never calls fetch when the URL fails validation", async () => { + resolvesTo("10.0.0.5"); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + await expect( + guardedFetch("https://rebind.example.com/"), + ).rejects.toThrow(/blocked network address/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("reuses a pinned dispatcher and disables redirects for public hosts", async () => { + resolvesTo("93.184.216.34"); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("ok", { status: 200 })); + + const res = await guardedFetch("https://public.example.com/x", { + method: "GET", + }); + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const init = fetchSpy.mock.calls[0][1] as RequestInit & { + dispatcher?: unknown; + }; + expect(init.redirect).toBe("manual"); + expect(init.dispatcher).toBeInstanceOf(Agent); + // Original request options are preserved. + expect(init.method).toBe("GET"); + + await guardedFetch("https://public.example.com/y"); + const nextInit = fetchSpy.mock.calls[1][1] as RequestInit & { + dispatcher?: unknown; + }; + expect(nextInit.dispatcher).toBe(init.dispatcher); + }); +}); diff --git a/backend/src/lib/mcp/client.ts b/backend/src/lib/mcp/client.ts index 27b8cdf44..ed2a800b7 100644 --- a/backend/src/lib/mcp/client.ts +++ b/backend/src/lib/mcp/client.ts @@ -1,6 +1,8 @@ import crypto from "crypto"; import dns from "dns/promises"; import net from "net"; +import { Agent } from "undici"; +import { isBlockedIp } from "../privateIp"; import { BLOCKED_METADATA_HOSTS, HEADER_NAME_RE, @@ -223,41 +225,8 @@ export function toConnectorSummary( }; } -function isPrivateIpv4(ip: string) { - const parts = ip.split(".").map((part) => Number.parseInt(part, 10)); - if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) { - return true; - } - const [a, b] = parts; - return ( - a === 0 || - a === 10 || - a === 127 || - (a === 100 && b >= 64 && b <= 127) || - (a === 169 && b === 254) || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 192 && b === 0) || - (a === 198 && (b === 18 || b === 19)) || - a >= 224 - ); -} - -function isPrivateIpv6(ip: string) { - const normalized = ip.toLowerCase(); - if (normalized === "::1" || normalized === "::") return true; - if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; - if (/^fe[89ab]:/.test(normalized)) return true; - const ipv4Tail = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); - return ipv4Tail ? isPrivateIpv4(ipv4Tail[1]) : false; -} - -function isBlockedIp(ip: string) { - const family = net.isIP(ip); - if (family === 4) return isPrivateIpv4(ip); - if (family === 6) return isPrivateIpv6(ip); - return true; -} +// Private/reserved IP classification lives in lib/privateIp.ts so every +// guarded egress check reuses the exact same ranges. export async function validateRemoteMcpUrl(rawUrl: string): Promise { let url: URL; @@ -282,9 +251,17 @@ export async function validateRemoteMcpUrl(rawUrl: string): Promise { throw new Error("MCP server URL points to a blocked host."); } - const literalFamily = net.isIP(hostname); + // URL.hostname wraps IPv6 literals in brackets ("[::1]"), which net.isIP + // does not recognize. Strip them so an IPv6 literal is classified by the + // private-IP guard rather than falling through to a DNS lookup that would + // treat the bracketed form as an (unresolvable) hostname. + const literalHost = + hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + const literalFamily = net.isIP(literalHost); const addresses = literalFamily - ? [{ address: hostname }] + ? [{ address: literalHost }] : await dns.lookup(hostname, { all: true, verbatim: true }); if (!addresses.length || addresses.some(({ address }) => isBlockedIp(address))) { throw new Error("MCP server URL resolves to a blocked network address."); @@ -352,6 +329,48 @@ export function authConfigPatch(config: McpConnectorAuthConfig): Record { + dns.lookup(hostname, { all: true, verbatim: true }) + .then((addresses) => { + if ( + !addresses.length || + addresses.some(({ address }) => isBlockedIp(address)) + ) { + callback( + new Error( + "MCP server URL resolves to a blocked network address.", + ), + [], + ); + return; + } + callback(null, addresses); + }) + .catch((err: unknown) => + callback( + err instanceof Error ? err : new Error(String(err)), + [], + ), + ); + }, + }, +}); + +// The single guarded egress helper for every outbound MCP request (connector +// transport, OAuth discovery/registration/refresh). It rejects non-HTTPS, +// credentialed, metadata-host and private-IP-literal URLs up front, pins the +// connection to a connect-time-validated address, and refuses to auto-follow +// redirects (`redirect: "manual"`) so a 3xx to an internal host cannot smuggle +// egress past the guard. export async function guardedFetch( input: Parameters[0], init?: Parameters[1], @@ -363,7 +382,11 @@ export async function guardedFetch( ? input.toString() : input.url; await validateRemoteMcpUrl(url); - return fetch(input, { ...init, redirect: "manual" }); + return fetch(input, { + ...init, + redirect: "manual", + dispatcher: guardedAgent, + } as RequestInit); } export function base64Url(buffer: Buffer) { diff --git a/backend/src/lib/mcp/oauth.ts b/backend/src/lib/mcp/oauth.ts index d03d597d7..96b16e404 100644 --- a/backend/src/lib/mcp/oauth.ts +++ b/backend/src/lib/mcp/oauth.ts @@ -45,8 +45,10 @@ function parseWwwAuthenticate(value: string | null): string | null { } async function fetchJson(url: string, init?: RequestInit) { - await validateRemoteMcpUrl(url); - const response = await fetch(url, { ...init, redirect: "manual" }); + // Route through the shared guarded egress helper so this call gets the same + // HTTPS-only / private-IP / connect-time-pinned / no-redirect protections as + // the connector transport (closes the raw-fetch SSRF gap in OAuth discovery). + const response = await guardedFetch(url, init); if (!response.ok) { throw new Error(`Failed to fetch OAuth metadata (${response.status}).`); } @@ -58,12 +60,14 @@ async function fetchJson(url: string, init?: RequestInit) { } async function discoverProtectedResourceMetadataUrl(serverUrl: string) { + // The MCP server URL is attacker-influenced, so both discovery probes go + // through the shared guarded egress helper rather than raw fetch (previously + // an unvalidated SSRF sink). const attempts: Array<() => Promise> = [ - () => fetch(serverUrl, { method: "GET", redirect: "manual" }), + () => guardedFetch(serverUrl, { method: "GET" }), () => - fetch(serverUrl, { + guardedFetch(serverUrl, { method: "POST", - redirect: "manual", headers: { Accept: "application/json, text/event-stream", "Content-Type": "application/json", @@ -189,10 +193,8 @@ async function registerOAuthClient( redirectUri: string, ) { if (!metadata.registrationEndpoint) return null; - await validateRemoteMcpUrl(metadata.registrationEndpoint); - const response = await fetch(metadata.registrationEndpoint, { + const response = await guardedFetch(metadata.registrationEndpoint, { method: "POST", - redirect: "manual", headers: { Accept: "application/json", "Content-Type": "application/json", @@ -329,8 +331,7 @@ async function refreshOAuthAccessToken(row: OAuthTokenRow, db: Db) { }); if (clientSecret) body.set("client_secret", clientSecret); if (row.resource) body.set("resource", row.resource); - await validateRemoteMcpUrl(row.token_endpoint); - const response = await fetch(row.token_endpoint, { + const response = await guardedFetch(row.token_endpoint, { method: "POST", headers: { Accept: "application/json", diff --git a/backend/src/lib/privateIp.ts b/backend/src/lib/privateIp.ts new file mode 100644 index 000000000..22f6b0015 --- /dev/null +++ b/backend/src/lib/privateIp.ts @@ -0,0 +1,146 @@ +import net from "net"; + +const blockedIpv4 = new net.BlockList(); +for (const [network, prefix] of [ + ["0.0.0.0", 8], // "This network" / unspecified + ["10.0.0.0", 8], // RFC 1918 private-use + ["100.64.0.0", 10], // Shared address space (carrier-grade NAT) + ["127.0.0.0", 8], // Loopback + ["169.254.0.0", 16], // Link-local + ["172.16.0.0", 12], // RFC 1918 private-use + ["192.0.0.0", 24], // IETF protocol assignments + ["192.0.2.0", 24], // Documentation (TEST-NET-1) + ["192.88.99.0", 24], // Deprecated 6to4 relay anycast + ["192.168.0.0", 16], // RFC 1918 private-use + ["198.18.0.0", 15], // Benchmarking + ["198.51.100.0", 24], // Documentation (TEST-NET-2) + ["203.0.113.0", 24], // Documentation (TEST-NET-3) + ["224.0.0.0", 4], // Multicast + ["240.0.0.0", 4], // Reserved (includes limited broadcast) +] as const) { + blockedIpv4.addSubnet(network, prefix, "ipv4"); +} + +const blockedIpv6 = new net.BlockList(); +for (const [network, prefix] of [ + ["2001::", 32], // Teredo + ["2001:2::", 48], // Benchmarking + ["2001:10::", 28], // Deprecated ORCHID + ["2001:db8::", 32], // Documentation + ["2002::", 16], // Deprecated 6to4 transition space + ["3fff::", 20], // Documentation +] as const) { + blockedIpv6.addSubnet(network, prefix, "ipv6"); +} + +/** + * SSRF guard helpers: classify an IP literal as private/reserved/unsafe. + * Shared by the MCP connector egress checks so every caller rejects the same + * ranges. Conservative: anything unparseable or unrecognized is treated as + * blocked. + */ +export function isPrivateIpv4(ip: string): boolean { + if (net.isIP(ip) !== 4) return true; + return blockedIpv4.check(ip, "ipv4"); +} + +/** + * Expand an IPv6 literal (possibly using `::` compression and/or a trailing + * dotted-quad IPv4 tail) into its eight 16-bit groups. Returns null if the + * input is not a well-formed IPv6 literal. Any embedded dotted IPv4 tail is + * folded into the final two hextets so callers can read the embedded address + * uniformly. + */ +function expandIpv6Groups(ip: string): number[] | null { + let s = ip.toLowerCase(); + const zone = s.indexOf("%"); + if (zone !== -1) s = s.slice(0, zone); + + // Fold a trailing dotted-quad IPv4 tail (e.g. `::ffff:1.2.3.4`) into two + // hex groups so the address is a pure list of hextets. + const dotted = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (dotted) { + const octets = dotted.slice(1, 5).map((o) => Number.parseInt(o, 10)); + if (octets.some((o) => o > 255)) return null; + const hi = ((octets[0] << 8) | octets[1]).toString(16); + const lo = ((octets[2] << 8) | octets[3]).toString(16); + s = s.slice(0, dotted.index) + `${hi}:${lo}`; + } + + const halves = s.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + + let groups: string[]; + if (halves.length === 2) { + const fill = 8 - head.length - tail.length; + if (fill < 0) return null; + groups = [...head, ...Array(fill).fill("0"), ...tail]; + } else { + groups = head; + } + if (groups.length !== 8) return null; + + const nums = groups.map((g) => Number.parseInt(g || "0", 16)); + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff)) { + return null; + } + return nums; +} + +function embeddedIpv4(hi: number, lo: number): string { + return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; +} + +export function isPrivateIpv6(ip: string): boolean { + const normalized = ip.toLowerCase().split("%", 1)[0]; + if (net.isIP(normalized) !== 6) return true; + + const groups = expandIpv6Groups(normalized); + if (!groups) return true; + + // IPv4-compatible ::/96 and IPv4-mapped ::ffff:0:0/96 are not globally + // reachable IPv6 destinations. Block the entire ranges, even when their + // embedded IPv4 value would otherwise be public. + if (groups.slice(0, 6).every((g) => g === 0)) { + return true; + } + if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) { + return true; + } + + // NAT64 well-known prefix 64:ff9b::/96 — last 32 bits are the target IPv4. + // This prefix is globally reachable, so permit it only when the embedded + // IPv4 destination is also globally reachable. + if ( + groups[0] === 0x64 && + groups[1] === 0xff9b && + groups[2] === 0 && + groups[3] === 0 && + groups[4] === 0 && + groups[5] === 0 + ) { + return isPrivateIpv4(embeddedIpv4(groups[6], groups[7])); + } + + // Normal globally routable IPv6 unicast lives in 2000::/3. Everything + // outside it is fail-closed, including unique-local fc00::/7, link-local + // fe80::/10, deprecated site-local fec0::/10, multicast ff00::/8, + // discard-only 100::/64, and local-use translation 64:ff9b:1::/48. + const isGlobalUnicast = (groups[0] & 0xe000) === 0x2000; + if (!isGlobalUnicast) return true; + + return blockedIpv6.check(normalized, "ipv6"); +} + +/** + * True if `ip` is a private/reserved/unsafe address. Non-IP input returns true + * (fail closed) — callers should pass resolved IP literals. + */ +export function isBlockedIp(ip: string): boolean { + const family = net.isIP(ip); + if (family === 4) return isPrivateIpv4(ip); + if (family === 6) return isPrivateIpv6(ip); + return true; +}