diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7e92b9d01..a4e74c58c 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -289,7 +289,7 @@ export const layer = Layer.effect( mcp: ConfigMCP.Info & { type: "remote" }, ) { yield* Effect.tryPromise({ - try: () => assertSafeUrl(mcp.url), + try: () => assertSafeUrl(mcp.url, { allowPrivateNetwork: true }), catch: (e) => new Error(e instanceof Error ? e.message : String(e)), }).pipe(Effect.orDie) diff --git a/packages/opencode/src/util/ssrf.ts b/packages/opencode/src/util/ssrf.ts index 155cbfc4e..527970056 100644 --- a/packages/opencode/src/util/ssrf.ts +++ b/packages/opencode/src/util/ssrf.ts @@ -6,49 +6,66 @@ const BLOCKED_HOSTNAMES = new Set([ "kubernetes.default.svc", ]) +const PRIVATE_IPV4_PREFIXES = ["10."] + +const PRIVATE_IPV4_RANGES: Array<{ start: number; end: number }> = [ + { start: ip4ToInt("172.16.0.0"), end: ip4ToInt("172.31.255.255") }, // private class B + { start: ip4ToInt("192.168.0.0"), end: ip4ToInt("192.168.255.255") }, // private class C +] + const BLOCKED_IPV4_PREFIXES = [ - "10.", // private class A "0.", // current network ] const BLOCKED_IPV4_RANGES: Array<{ start: number; end: number }> = [ - { start: ip4ToInt("172.16.0.0"), end: ip4ToInt("172.31.255.255") }, // private class B - { start: ip4ToInt("192.168.0.0"), end: ip4ToInt("192.168.255.255") }, // private class C { start: ip4ToInt("169.254.0.0"), end: ip4ToInt("169.254.255.255") }, // link-local { start: ip4ToInt("100.64.0.0"), end: ip4ToInt("100.127.255.255") }, // shared address (CGN) { start: ip4ToInt("100.100.100.200"), end: ip4ToInt("100.100.100.200") }, // Alibaba Cloud metadata ] +type SafeUrlOptions = { + allowPrivateNetwork?: boolean +} + function ip4ToInt(ip: string): number { const parts = ip.split(".") return ((+parts[0]! << 24) | (+parts[1]! << 16) | (+parts[2]! << 8) | +parts[3]!) >>> 0 } -function isBlockedIPv4(ip: string): boolean { - for (const prefix of BLOCKED_IPV4_PREFIXES) { +function inRanges(ip: string, ranges: Array<{ start: number; end: number }>) { + const n = ip4ToInt(ip) + return ranges.some((range) => n >= range.start && n <= range.end) +} + +function isPrivateIPv4(ip: string) { + for (const prefix of PRIVATE_IPV4_PREFIXES) { if (ip.startsWith(prefix)) return true } - const n = ip4ToInt(ip) - for (const range of BLOCKED_IPV4_RANGES) { - if (n >= range.start && n <= range.end) return true + return inRanges(ip, PRIVATE_IPV4_RANGES) +} + +function isBlockedIPv4(ip: string, options: SafeUrlOptions = {}): boolean { + if (!options.allowPrivateNetwork && isPrivateIPv4(ip)) return true + for (const prefix of BLOCKED_IPV4_PREFIXES) { + if (ip.startsWith(prefix)) return true } - return false + return inRanges(ip, BLOCKED_IPV4_RANGES) } -function isBlockedIPv6(ip: string): boolean { +function isBlockedIPv6(ip: string, options: SafeUrlOptions = {}): boolean { const normalized = ip.toLowerCase() if (normalized.startsWith("fe80:")) return true // link-local - if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true // ULA + if (!options.allowPrivateNetwork && (normalized.startsWith("fc") || normalized.startsWith("fd"))) return true // ULA // IPv4-mapped IPv6 in dotted-decimal form (::ffff:a.b.c.d) const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/) - if (mapped) return isBlockedIPv4(mapped[1]!) + if (mapped) return isBlockedIPv4(mapped[1]!, options) // IPv4-mapped IPv6 in hex form (::ffff:HHHH:HHHH) — URL parsers normalize to this const hexMapped = normalized.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) if (hexMapped) { const hi = parseInt(hexMapped[1]!, 16) const lo = parseInt(hexMapped[2]!, 16) const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}` - return isBlockedIPv4(ipv4) + return isBlockedIPv4(ipv4, options) } return false } @@ -76,7 +93,7 @@ export async function safeFetch( throw new Error("SSRF protection: too many redirects") } -export async function assertSafeUrl(url: string): Promise { +export async function assertSafeUrl(url: string, options: SafeUrlOptions = {}): Promise { const parsed = new URL(url) const hostname = parsed.hostname.replace(/^\[|\]$/g, "") @@ -86,7 +103,7 @@ export async function assertSafeUrl(url: string): Promise { // Numeric IPv4 check (before DNS) if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { - if (isBlockedIPv4(hostname)) { + if (isBlockedIPv4(hostname, options)) { throw new Error(`SSRF protection: blocked private/internal IP "${hostname}"`) } return @@ -94,7 +111,7 @@ export async function assertSafeUrl(url: string): Promise { // Numeric IPv6 check (before DNS) if (hostname.includes(":")) { - if (isBlockedIPv6(hostname)) { + if (isBlockedIPv6(hostname, options)) { throw new Error(`SSRF protection: blocked private/internal IPv6 "${hostname}"`) } return @@ -103,10 +120,10 @@ export async function assertSafeUrl(url: string): Promise { // DNS resolution check to prevent DNS rebinding try { const { address, family } = await lookup(hostname) - if (family === 4 && isBlockedIPv4(address)) { + if (family === 4 && isBlockedIPv4(address, options)) { throw new Error(`SSRF protection: hostname "${hostname}" resolves to blocked IP "${address}"`) } - if (family === 6 && isBlockedIPv6(address)) { + if (family === 6 && isBlockedIPv6(address, options)) { throw new Error(`SSRF protection: hostname "${hostname}" resolves to blocked IPv6 "${address}"`) } } catch (e: any) { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 833576c1e..18a6be58b 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -795,6 +795,26 @@ test( ), ) +test( + "remote MCP can connect to a user-configured Docker bridge address", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "docker-bridge" + const serverState = getOrCreateClientState("docker-bridge") + + const addResult = yield* mcp.add("docker-bridge", { + type: "remote", + url: "http://172.17.0.1:3845/mcp", + oauth: false, + }) + + const serverStatus = (addResult.status as any)["docker-bridge"] ?? addResult.status + expect(serverStatus.status).toBe("connected") + expect(serverState.listToolsCalls).toBe(1) + }), + ), +) + // ======================================================================== // Test: transport leak — failed remote transports not closed (#19168) // ======================================================================== diff --git a/packages/opencode/test/util/ssrf.test.ts b/packages/opencode/test/util/ssrf.test.ts index 258095a2f..15a47914c 100644 --- a/packages/opencode/test/util/ssrf.test.ts +++ b/packages/opencode/test/util/ssrf.test.ts @@ -70,6 +70,26 @@ describe("assertSafeUrl", () => { }) }) + describe("allowPrivateNetwork option", () => { + test("allows explicitly trusted RFC1918 endpoints", async () => { + await expect(assertSafeUrl("http://10.0.0.1/", { allowPrivateNetwork: true })).resolves.toBeUndefined() + await expect(assertSafeUrl("http://172.17.0.1/", { allowPrivateNetwork: true })).resolves.toBeUndefined() + await expect(assertSafeUrl("http://192.168.1.10/", { allowPrivateNetwork: true })).resolves.toBeUndefined() + }) + + test("still blocks metadata and link-local endpoints", async () => { + await expect(assertSafeUrl("http://169.254.169.254/", { allowPrivateNetwork: true })).rejects.toThrow( + "SSRF protection", + ) + await expect(assertSafeUrl("http://100.100.100.200/", { allowPrivateNetwork: true })).rejects.toThrow( + "SSRF protection", + ) + await expect(assertSafeUrl("http://metadata.google.internal/", { allowPrivateNetwork: true })).rejects.toThrow( + "SSRF protection", + ) + }) + }) + describe("DNS fail-closed", () => { test("rejects unresolvable hostnames", async () => { await expect(assertSafeUrl("http://this-domain-definitely-does-not-exist-xyz123.invalid/")).rejects.toThrow(