GHSA-v228-72c7-fx8j: open-websearch has SSRF in `fetchWebContent` MCP tool: bracketed IPv6 literals and non-resolving hostname check bypass `isPrivateOrLocalHostname`
Summary`src/utils/urlSafety.ts` exposes `isPublicHttpUrl` / `assertPublicHttpUrl`, used to gate the MCP `fetchWebContent` tool against private-network targets. The check has two defects that together allow **non-blind SSRF with the response body returned to the caller**:**Bracketed IPv6 literals are never recognized.** Node's WHATWG `URL.hostname` keeps the surrounding `[…]` for IPv6 literals. `isIP("[::1]")` returns 0 (not 6), so neither `isPrivateIpv4` nor `isPrivateIpv6` is ever called on an IPv6 literal input — including `[::1]` itself, and including every IPv4-mapped form such as `[::ffff:7f00:1]` (= 127.0.0.1 via the IPv4 stack).**No DNS resolution.** `isPrivateOrLocalHostname` only inspects the literal `hostname` string. It never resolves the host to an IP. Any attacker-controlled hostname whose DNS record points at 127.0.0.1 (or any RFC1918 / link-local address) passes the check unchanged, and `axios` then performs its own resolution and connects to the private address.The `isPrivateIpv6` implementation also has the hex bypass (it would miss `::ffff:7f00:1` even if reached) but defect (1) makes every bracketed IPv6 literal slip past before that branch is even entered.The `fetchWebContent` tool returns the response body (`JSON.stringify(result)`) to the MCP caller, so the SSRF is non-blind.DetailsInput URL | parsed.hostname | isPublicHttpUrl | assertPublicHttpUrl
-- | -- | -- | --
http://[::ffff:7f00:1]/ (127.0.0.1) | [::ffff:7f00:1] | true ← bypass | PASSED ← bypass
http://[::ffff:a9fe:1]/ (169.254.0.1) | [::ffff:a9fe:1] | true ← bypass | PASSED ← bypass
http://[::ffff:a00:1]/ (10.0.0.1) | [::ffff:a00:1] | true ← bypass | PASSED ← bypass
http://[::ffff:127.0.0.1]/ | [::ffff:7f00:1] | true ← bypass | PASSED ← bypass
http://[0:0:0:0:0:0:0:1]/ | [::1] | true ← bypass | PASSED ← bypass
http://[::1]/ (plain loopback!) | [::1] | true ← bypass | PASSED ← bypass
http://127.0.0.1/ (control) | 127.0.0.1 | false (blocked) | threw (blocked)
http://localhost/ (control) | localhost | false (blocked) | threw (blocked)Environment: clean clone of `Aas-ee/open-webSearch@HEAD`, Node 22+.**1. Start the MCP HTTP server.****2. Stand up a canary on loopback.****3. Open an MCP session and call `fetchWebContent` with the bypass URL.**Expected result: the canary logs `[canary] GET /internal from 127.0.0.1`, and the MCP response contains `INTERNAL-SECRET: canary-hit for /internal` in the tool's `content[0].text`.Additional bypass vectors that work the same way:`http://[::1]:<port>/` — plain IPv6 loopback.`http://[::ffff:a9fe:1]/latest/meta-data/iam/security-credentials/` — AWS EC2 metadata over the IPv4 stack.`http://attacker.example/` where `attacker.example` has A/AAAA pointing at any private address — bypasses via defect (2), no IPv6 trick needed.Impact**Cross-tenant SSRF with full response body.** Any client that can speak MCP to the HTTP transport can fetch arbitrary private-network URLs and receive the response body. AWS EC2 metadata, internal dashboards, loopback services, RFC1918 neighbours — all in scope.**Pre-auth when `enableHttpServer` is set.** No authentication layer exists on `/mcp` or `/sse`; CORS is `*`.**DNS-rebinding / LAN-victim angle.** Because `/mcp` is CORS `*` and accepts `POST`, a victim who visits an attacker-controlled webpage while running open-webSearch locally will have their browser used to send tool-call requests, and the tool's response can be exfiltrated back via a simple XHR.**Exploitable over stdio too.** Even with HTTP disabled, a compromised or prompt-injected MCP client can call `fetchWebContent` against loopback on the host running the server — a realistic LLM-agent-abuse vector.No meaningful mitigation in the call chain: only `http://` and `https://` schemes are accepted, but that is not a restriction for SSRF.Suggested fixTwo changes, either of which individually closes most of the gap; both together close it fully.**Normalize the hostname before IP checks, and perform a DNS resolution.** Use the `ip-address` package or a similar canonicalizer, and reject any `getaddrinfo` result whose IP falls in a private CIDR. Keep a bracket-stripping step for IPv6 literals before calling `isIP()`.**Dual-pin the connection.** Even a perfect pre-connect check has TOCTOU gaps (DNS rebinding between check and `axios.get`). Use a custom `undici` `Agent` whose `connect` hook validates the actual connected socket IP via `socket.remoteAddress`. That closes the rebinding window.**Gate the HTTP transport.** Require a bearer token (env var) on `/mcp` and `/sse`, and restrict binding to `127.0.0.1` by default. CORS `*` plus no-auth on `0.0.0.0` is the same exposure profile as an unauthenticated open proxy.Test vectors to add to the suite:
CVSS v3.1: 8.2