diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c1b53..4efec39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v0.4.37 (2026-05-13) + +## Improvements +- Harden Cowork MCP plugin handling + # v0.4.36 (2026-05-13) ## Features diff --git a/cli/package.json b/cli/package.json index 18e2fd2..dcc1bde 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.4.36", + "version": "0.4.37", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/package.json b/package.json index 82480ec..1715414 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.36", + "version": "0.4.37", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/api/cli-tools/cowork-settings/route.js b/src/app/api/cli-tools/cowork-settings/route.js index 2ed1fe7..6fcf418 100644 --- a/src/app/api/cli-tools/cowork-settings/route.js +++ b/src/app/api/cli-tools/cowork-settings/route.js @@ -5,7 +5,7 @@ import fs from "fs/promises"; import path from "path"; import os from "os"; import crypto from "crypto"; -import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins"; +import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, ALLOWED_MCP_COMMANDS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins"; import { UPDATER_CONFIG } from "@/shared/constants/config"; import { DATA_DIR } from "@/lib/dataDir"; @@ -309,8 +309,18 @@ export async function POST(request) { // Register custom stdio plugins into bridge + persist for restart survival. 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, args: p.args || [] })); - for (const p of stdioCustoms) registerCustomPlugin(p); + const stdioCustoms = customPluginsArray + .filter((p) => p && typeof p.command === "string" && p.command.trim()) + .filter((p) => ALLOWED_MCP_COMMANDS.has(path.basename(p.command))) + .map((p) => ({ + name: String(p.name || "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64), + command: p.command, + args: Array.isArray(p.args) ? p.args.map(String) : [], + })) + .filter((p) => p.name); + for (const p of stdioCustoms) { + try { registerCustomPlugin(p); } catch { /* skip invalid */ } + } try { const dir = path.join(DATA_DIR, "mcp"); await fs.mkdir(dir, { recursive: true }); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 49d8831..6fbbdd0 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -30,8 +30,37 @@ const PROTECTED_API_PATHS = [ "/api/keys", "/api/providers/client", "/api/provider-nodes/validate", + "/api/cli-tools", + "/api/mcp", ]; +// Routes that spawn child processes — restrict to localhost regardless of auth. +const LOCAL_ONLY_PATHS = [ + "/api/cli-tools/cowork-settings", + "/api/mcp/", +]; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +function isLoopbackHostname(h) { + if (!h) return false; + const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase(); + return LOOPBACK_HOSTS.has(name); +} + +// Same-host gate: Host header must be loopback AND (if present) Origin must match. +// Defends against tunnel/LAN access, remote browser CSRF, and cross-site form posts. +function isLocalRequest(request) { + if (!isLoopbackHostname(request.headers.get("host"))) return false; + const origin = request.headers.get("origin"); + if (origin) { + try { + if (!isLoopbackHostname(new URL(origin).hostname)) return false; + } catch { return false; } + } + return true; +} + async function hasValidToken(request) { const token = request.cookies.get("auth_token")?.value; return await verifyDashboardAuthToken(token); @@ -56,6 +85,13 @@ async function isAuthenticated(request) { export async function proxy(request) { const { pathname } = request.nextUrl; + // Local-only gate for spawn-capable routes (CVE GHSA-fhh6-4qxv-rpqj). + if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) { + if (!isLocalRequest(request)) { + return NextResponse.json({ error: "Local only: MCP requires localhost access" }, { status: 403 }); + } + } + // Always protected - require valid JWT or local CLI token (machineId-based) if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) { if (await hasValidCliToken(request) || await hasValidToken(request)) diff --git a/src/lib/mcp/stdioSseBridge.js b/src/lib/mcp/stdioSseBridge.js index 9554353..d68d0b1 100644 --- a/src/lib/mcp/stdioSseBridge.js +++ b/src/lib/mcp/stdioSseBridge.js @@ -5,7 +5,7 @@ const { spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); -const { LOCAL_STDIO_PLUGINS } = require("@/shared/constants/coworkPlugins"); +const { LOCAL_STDIO_PLUGINS, ALLOWED_MCP_COMMANDS } = require("@/shared/constants/coworkPlugins"); const { DATA_DIR } = require("@/lib/dataDir"); const CUSTOM_FILE = path.join(DATA_DIR, "mcp", "customPlugins.json"); @@ -111,18 +111,26 @@ const getCustomStore = () => { return globalThis.__9routerCustomPlugins; }; +function isAllowedCommand(cmd) { + const bin = path.basename(String(cmd || "")); + return ALLOWED_MCP_COMMANDS.has(bin); +} + function registerCustomPlugin(def) { + if (!isAllowedCommand(def?.command)) { + throw new Error(`Blocked: command '${def?.command}' not in MCP allowlist`); + } getCustomStore().set(def.name, def); } function findPlugin(name) { const fromMem = getCustomStore().get(name) || LOCAL_STDIO_PLUGINS.find((p) => p.name === name); if (fromMem) return fromMem; - // Lazy-load custom plugins from disk (survives app restart). + // Lazy-load custom plugins from disk (survives app restart); re-validate allowlist. try { const list = JSON.parse(fs.readFileSync(CUSTOM_FILE, "utf-8")); const def = Array.isArray(list) ? list.find((p) => p.name === name && p.command) : null; - if (def) { getCustomStore().set(def.name, def); return def; } + if (def && isAllowedCommand(def.command)) { getCustomStore().set(def.name, def); return def; } } catch { /* file missing or invalid */ } return null; } diff --git a/src/proxy.js b/src/proxy.js index e91fd54..d0b5647 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -10,5 +10,7 @@ export const config = { "/api/keys/:path*", "/api/providers/client", "/api/provider-nodes/validate", + "/api/cli-tools/:path*", + "/api/mcp/:path*", ], }; diff --git a/src/shared/constants/coworkPlugins.js b/src/shared/constants/coworkPlugins.js index b32f120..592d856 100644 --- a/src/shared/constants/coworkPlugins.js +++ b/src/shared/constants/coworkPlugins.js @@ -69,4 +69,7 @@ function buildManagedMcpServers(plugins) { return out; } -module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers }; +// Allowlist of executables that may be spawned for custom stdio MCP plugins. +const ALLOWED_MCP_COMMANDS = new Set(["npx", "node", "uvx", "python", "python3", "bunx", "bun"]); + +module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, ALLOWED_MCP_COMMANDS, buildManagedMcpServers };