GitHub Security Advisory: GHSA-v228-72c7-fx8j
Package: open-websearch (NPM)
Severity: HIGH (CVSS 8.2)
CWE: CWE-20: Improper Input Validation, CWE-693: Protection Mechanism Failure, CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: <= 2.1.6
Patched in: 2.1.7

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

Details:
### 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**:

1. **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).
2. **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.

### Details
<!-- obsidian --><p><strong>Vulnerable function</strong> — <code>src/utils/urlSafety.ts:95-119</code>:</p>
<pre><code class="language-ts">export function isPrivateOrLocalHostname(hostname: string): boolean {
  const host = hostname.trim().toLowerCase();
  if (!host) return true;
  if (host === 'localhost' || host.endsWith('.localhost')) return true;
  if (host === 'metadata.google.internal' || host === 'metadata.azure.internal') return true;
  const integerIp = parseIntegerIpv4Literal(host);
  if (integerIp &#x26;&#x26; isPrivateIpv4(integerIp)) return true;
  if (isPrivateOrLocalIp(host)) return true;   // only runs if isIP(host) ∈ {4, 6}
  return false;
}
</code></pre>
<p><code>isPrivateOrLocalIp</code> — <code>src/utils/urlSafety.ts:84-93</code>:</p>
<pre><code class="language-ts">function isPrivateOrLocalIp(ip: string): boolean {
  const version = isIP(ip);  // returns 0 for "[::1]", "[::ffff:7f00:1]", any bracketed literal
  if (version === 4) return isPrivateIpv4(ip);
  if (version === 6) return isPrivateIpv6(ip);
  return false;
}
</code></pre>
<p>Caller — <code>src/tools/setupTools.ts:252-286</code> (<code>fetchWebContent</code> tool):</p>
<pre><code class="language-ts">server.tool(
  fetchWebToolName,  // default: "fetchWebContent"
  "Fetch content from a public HTTP(S) URL ...",
  { url: z.string().url().refine(
      (url) => validatePublicWebUrl(url),   // → isPublicHttpUrl → isPrivateOrLocalHostname
      "URL must be a public HTTP(S) address ..."
    ), /* … */ },
  async ({url, maxChars}) => {
    const result = await runtime.services.fetchWeb.execute({ url, maxChars, /*…*/ });
    return { content: [{ type: