Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
53 changes: 35 additions & 18 deletions packages/opencode/src/util/ssrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -76,7 +93,7 @@ export async function safeFetch(
throw new Error("SSRF protection: too many redirects")
}

export async function assertSafeUrl(url: string): Promise<void> {
export async function assertSafeUrl(url: string, options: SafeUrlOptions = {}): Promise<void> {
const parsed = new URL(url)
const hostname = parsed.hostname.replace(/^\[|\]$/g, "")

Expand All @@ -86,15 +103,15 @@ export async function assertSafeUrl(url: string): Promise<void> {

// 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
}

// 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
Expand All @@ -103,10 +120,10 @@ export async function assertSafeUrl(url: string): Promise<void> {
// 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) {
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ========================================================================
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/test/util/ssrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down