This commit is contained in:
decolua 2026-05-13 20:35:42 +07:00
parent 7f7b86f70e
commit 992f4db4a0
8 changed files with 73 additions and 9 deletions

View file

@ -1,3 +1,8 @@
# v0.4.37 (2026-05-13)
## Improvements
- Harden Cowork MCP plugin handling
# v0.4.36 (2026-05-13)
## Features

View file

@ -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"

View file

@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.4.36",
"version": "0.4.37",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View file

@ -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 });

View file

@ -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))

View file

@ -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;
}

View file

@ -10,5 +10,7 @@ export const config = {
"/api/keys/:path*",
"/api/providers/client",
"/api/provider-nodes/validate",
"/api/cli-tools/:path*",
"/api/mcp/:path*",
],
};

View file

@ -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 };