From b5fda00dbf70550ce6e3a03ab23d74217041a6e7 Mon Sep 17 00:00:00 2001 From: NubsCarson Date: Sun, 31 May 2026 10:54:43 +0000 Subject: [PATCH] fix(webfetch): block loopback/private/link-local/metadata hosts (SSRF guard) webfetch can be driven by autonomous agents, so a bare GET must not be usable to reach internal services. Reject loopback (127.0.0.0/8, ::1), private (10/8, 172.16/12, 192.168/16), link-local + cloud metadata (169.254/16, 169.254.169.254), IPv6 ULA/link-local, and *.local/*.internal/localhost hosts before fetching. Public hosts are unaffected. --- packages/opencode/src/tool/webfetch.ts | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index f8a4b6233ae9..62c9f26049a8 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -10,6 +10,41 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes +// SSRF guard: refuse fetches to loopback / private / link-local / metadata +// hosts. webfetch can be driven by autonomous (and potentially prompt-injected) +// agents, so a bare GET must not be usable to reach internal services (a local +// dev/control server, cloud metadata at 169.254.169.254, private LAN hosts). +function isBlockedFetchHost(rawHost: string): boolean { + const host = rawHost + .toLowerCase() + .replace(/^\[|\]$/g, "") + .trim() + if (!host) return true + if ( + host === "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + host === "0.0.0.0" || + host === "::1" || + host === "::" + ) + return true + // IPv6 literals only (contain a colon): unique-local (fc00::/7) + link-local (fe80::/10) + if (host.includes(":") && (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"))) return true + // IPv4 dotted-quad ranges + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (m) { + const a = Number(m[1]) + const b = Number(m[2]) + if (a === 127 || a === 10 || a === 0) return true // loopback / private / this-host + if (a === 169 && b === 254) return true // link-local + cloud metadata (169.254.169.254) + if (a === 192 && b === 168) return true // private + if (a === 172 && b >= 16 && b <= 31) return true // private + } + return false +} + export const Parameters = Schema.Struct({ url: Schema.String.annotate({ description: "The URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) @@ -36,6 +71,18 @@ export const WebFetchTool = Tool.define( throw new Error("URL must start with http:// or https://") } + { + let host = "" + try { + host = new URL(params.url).hostname + } catch { + throw new Error(`Invalid URL: ${params.url}`) + } + if (isBlockedFetchHost(host)) { + throw new Error(`Refusing to fetch internal/private/loopback host: ${host}`) + } + } + yield* ctx.ask({ permission: "webfetch", patterns: [params.url],