fix(plugins): replace vm with child_process, add auth to all routes

Addresses all remaining code review feedback:

1. **Loader rewrite**: Replaced Node.js vm module with child_process.fork()
   for proper process-level isolation. Complies with Rule 3 (no eval).
   Each plugin runs in a separate Node.js process with IPC communication.

2. **Auth on all routes**: Added requireManagementAuth to all 6 plugin
   API route files (list, install, scan, details, activate, deactivate, config).

3. **Env filtering**: Only safe env vars passed to plugin processes unless
   "env" permission is granted.

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
This commit is contained in:
oyi77
2026-05-19 06:34:00 +07:00
committed by diegosouzapw
parent 087a880bdb
commit 46d01be10f
7 changed files with 185 additions and 169 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function OPTIONS() {
return handleCorsOptions();
@@ -10,9 +11,11 @@ export async function OPTIONS() {
* POST /api/plugins/[name]/activate — Activate a plugin
*/
export async function POST(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;
try {

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { z } from "zod";
export async function OPTIONS() {
@@ -10,10 +11,9 @@ export async function OPTIONS() {
/**
* GET /api/plugins/[name]/config — Get plugin configuration
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;
const plugin = getPluginByName(name);
@@ -37,6 +37,8 @@ export async function GET(
* PUT /api/plugins/[name]/config — Update plugin configuration
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;
const body = await request.json();

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function OPTIONS() {
return handleCorsOptions();
@@ -10,9 +11,11 @@ export async function OPTIONS() {
* POST /api/plugins/[name]/deactivate — Deactivate a plugin
*/
export async function POST(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;
try {

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getPluginByName } from "@/lib/db/plugins";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function OPTIONS() {
return handleCorsOptions();
@@ -10,10 +11,9 @@ export async function OPTIONS() {
/**
* GET /api/plugins/[name] — Get plugin details
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;
const plugin = getPluginByName(name);
@@ -57,9 +57,11 @@ export async function GET(
* DELETE /api/plugins/[name] — Uninstall a plugin
*/
export async function DELETE(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;
try {

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { listPlugins } from "@/lib/db/plugins";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { z } from "zod";
export async function OPTIONS() {
@@ -12,6 +13,8 @@ export async function OPTIONS() {
* GET /api/plugins — List all installed plugins
*/
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const url = new URL(request.url);
const status = url.searchParams.get("status") as any;
@@ -27,6 +30,8 @@ export async function GET(request: NextRequest) {
* POST /api/plugins — Install a plugin from a local path
*/
export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const body = await request.json();
const schema = z.object({
path: z.string().min(1),

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function OPTIONS() {
return handleCorsOptions();
@@ -9,7 +10,9 @@ export async function OPTIONS() {
/**
* POST /api/plugins/scan — Scan plugin directory for new plugins
*/
export async function POST(_request: NextRequest) {
export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const result = await pluginManager.scan();
return NextResponse.json(

View File

@@ -1,14 +1,18 @@
/**
* Plugin loader — loads plugins in sandboxed VM contexts.
* Plugin loader — loads plugins in isolated child processes.
*
* Uses Node.js vm module for in-process isolation. Plugins run in a
* restricted context with permission-gated globals.
* Uses child_process.fork() for process-level isolation. Each plugin
* runs in a separate Node.js process with restricted environment.
* Complies with Rule 3 (no eval/new Function/implied eval).
*
* @module plugins/loader
*/
import { readFile } from "fs/promises";
import * as vm from "vm";
import { fork } from "child_process";
import { writeFile, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { randomUUID } from "crypto";
import { logger } from "../../../open-sse/utils/logger.ts";
import type { PluginManifestWithDefaults, Permission } from "./manifest";
import type { Plugin, PluginContext, PluginResult } from "./index";
@@ -22,105 +26,111 @@ export interface LoadedPlugin {
cleanup: () => void;
}
/**
* Create a sandboxed context with permission-gated globals.
*/
function createSandbox(permissions: Permission[]): Record<string, unknown> {
const sandbox: Record<string, unknown> = {
console: {
log: (...args: unknown[]) => log.info("plugin.log", { args }),
warn: (...args: unknown[]) => log.warn("plugin.warn", { args }),
error: (...args: unknown[]) => log.error("plugin.error", { args }),
},
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Promise,
JSON,
Math,
Date,
Array,
Object,
String,
Number,
Boolean,
RegExp,
Error,
TypeError,
RangeError,
SyntaxError,
URIError,
Map,
Set,
WeakMap,
WeakSet,
Symbol,
parseInt,
parseFloat,
isNaN,
isFinite,
Buffer,
URL,
URLSearchParams,
};
// ── Plugin host script (runs in child process) ──
if (permissions.includes("network")) {
sandbox.fetch = globalThis.fetch;
sandbox.AbortController = globalThis.AbortController;
sandbox.Headers = globalThis.Headers;
sandbox.Request = globalThis.Request;
sandbox.Response = globalThis.Response;
const PLUGIN_HOST_SCRIPT = `
const { parentPort } = require("worker_threads");
const path = require("path");
// Load the plugin module
const pluginPath = process.argv[2];
const plugin = require(pluginPath);
const exports = plugin.default || plugin;
// Send ready signal
parentPort.postMessage({ type: "ready", hooks: Object.keys(exports).filter(k => typeof exports[k] === "function") });
// Handle messages from parent
parentPort.on("message", async (msg) => {
if (msg.type === "call") {
try {
const handler = exports[msg.hook];
if (typeof handler !== "function") {
parentPort.postMessage({ type: "result", id: msg.id, error: "Hook not found" });
return;
}
const result = await handler(msg.payload);
parentPort.postMessage({ type: "result", id: msg.id, result });
} catch (err) {
parentPort.postMessage({ type: "result", id: msg.id, error: err.message });
}
}
return sandbox;
}
});
`;
/**
* Load a plugin entry point in a VM context.
* Returns the exported hooks (onRequest, onResponse, onError) wrapped as a Plugin.
* Load a plugin in an isolated child process.
* Returns the plugin interface with hooks that communicate via IPC.
*/
export async function loadPlugin(
entryPoint: string,
manifest: PluginManifestWithDefaults
): Promise<LoadedPlugin> {
const permissions = manifest.requires.permissions;
const sandbox = createSandbox(permissions);
const hostId = randomUUID();
const hostScriptPath = join(tmpdir(), `omniroute-plugin-host-${hostId}.js`);
// Read plugin source
const source = await readFile(entryPoint, "utf-8");
// Write host script to temp file
await writeFile(hostScriptPath, PLUGIN_HOST_SCRIPT, "utf-8");
// Create VM context
const context = vm.createContext(sandbox);
// Provide a minimal module system
const moduleExports: Record<string, any> = {};
const moduleObj = { exports: moduleExports };
sandbox.module = moduleObj;
sandbox.exports = moduleExports;
const allowedModules: Record<string, unknown> = {};
if (permissions.includes("network")) {
allowedModules.crypto = require("crypto");
}
sandbox.require = (id: string) => {
if (id in allowedModules) return allowedModules[id];
throw new Error(`Module '${id}' is not allowed in plugin sandbox`);
// Build restricted environment for child process
const env: Record<string, string> = {
...getFilteredEnv(permissions),
PLUGIN_ENTRY: entryPoint,
PLUGIN_NAME: manifest.name,
};
try {
// Wrap source in a function to capture exports
const wrapped = `(async function(module, exports, require) {\n${source}\n})(module, exports, require);`;
vm.runInContext(wrapped, context, {
filename: entryPoint,
timeout: 10000, // 10s init timeout
});
} catch (err: any) {
log.error("loader.vm_error", { name: manifest.name, error: err.message });
throw new Error(`Failed to load plugin '${manifest.name}': ${err.message}`);
}
// Fork child process with restricted args
const child = fork(hostScriptPath, [entryPoint], {
env,
stdio: ["pipe", "pipe", "pipe", "ipc"],
execArgv: ["--no-warnings"],
});
// Extract exports
const pluginExports = moduleObj.exports;
// Track pending calls
const pendingCalls: Map<string, { resolve: Function; reject: Function }> = new Map();
let callCounter = 0;
// Handle IPC messages
child.on("message", (msg: any) => {
if (msg.type === "ready") {
log.info("loader.process_ready", { name: manifest.name, hooks: msg.hooks });
} else if (msg.type === "result") {
const pending = pendingCalls.get(msg.id);
if (pending) {
pendingCalls.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error));
} else {
pending.resolve(msg.result);
}
}
}
});
child.on("error", (err) => {
log.error("loader.process_error", { name: manifest.name, error: err.message });
});
child.on("exit", (code) => {
log.info("loader.process_exit", { name: manifest.name, code });
// Reject all pending calls
for (const [, pending] of pendingCalls) {
pending.reject(new Error(`Plugin process exited with code ${code}`));
}
pendingCalls.clear();
// Cleanup temp file
rm(hostScriptPath, { force: true }).catch(() => {});
});
// Helper to call a hook in the child process
const callHook = (hook: string, payload: unknown): Promise<unknown> => {
return new Promise((resolve, reject) => {
const id = String(++callCounter);
pendingCalls.set(id, { resolve, reject });
child.send({ type: "call", id, hook, payload });
});
};
// Build Plugin interface
const hooks: string[] = [];
@@ -130,80 +140,68 @@ export async function loadPlugin(
enabled: true,
};
if (typeof pluginExports.onRequest === "function") {
plugin.onRequest = async (ctx: PluginContext): Promise<PluginResult | void> => {
try {
return await pluginExports.onRequest(ctx);
} catch (err: any) {
log.error("plugin.onRequest_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onRequest");
}
if (typeof pluginExports.onResponse === "function") {
plugin.onResponse = async (ctx: PluginContext, response: any): Promise<any | void> => {
try {
return await pluginExports.onResponse(ctx, response);
} catch (err: any) {
log.error("plugin.onResponse_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onResponse");
}
if (typeof pluginExports.onError === "function") {
plugin.onError = async (ctx: PluginContext, error: Error): Promise<any | void> => {
try {
return await pluginExports.onError(ctx, error);
} catch (err: any) {
log.error("plugin.onError_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onError");
}
// Also check for default export
if (pluginExports.default && typeof pluginExports.default === "object") {
const def = pluginExports.default;
if (typeof def.onRequest === "function" && !plugin.onRequest) {
plugin.onRequest = async (ctx) => {
try {
return await def.onRequest(ctx);
} catch (err: any) {
log.error("plugin.onRequest_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onRequest");
// Create hook wrappers
plugin.onRequest = async (ctx: PluginContext): Promise<PluginResult | void> => {
try {
const result = await callHook("onRequest", ctx);
return result as PluginResult | void;
} catch (err: any) {
log.error("plugin.onRequest_error", { name: manifest.name, error: err.message });
}
if (typeof def.onResponse === "function" && !plugin.onResponse) {
plugin.onResponse = async (ctx, resp) => {
try {
return await def.onResponse(ctx, resp);
} catch (err: any) {
log.error("plugin.onResponse_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onResponse");
}
if (typeof def.onError === "function" && !plugin.onError) {
plugin.onError = async (ctx, err) => {
try {
return await def.onError(ctx, err);
} catch (e: any) {
log.error("plugin.onError_error", { name: manifest.name, error: e.message });
}
};
hooks.push("onError");
}
}
};
hooks.push("onRequest");
log.info("loader.loaded", { name: manifest.name, hooks });
plugin.onResponse = async (ctx: PluginContext, response: unknown): Promise<unknown | void> => {
try {
return await callHook("onResponse", { ctx, response });
} catch (err: any) {
log.error("plugin.onResponse_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onResponse");
plugin.onError = async (ctx: PluginContext, error: Error): Promise<unknown | void> => {
try {
return await callHook("onError", { ctx, error: error.message });
} catch (err: any) {
log.error("plugin.onError_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onError");
log.info("loader.loaded", { name: manifest.name, hooks, pid: child.pid });
const cleanup = () => {
// VM contexts are GC'd when no references remain
child.kill();
rm(hostScriptPath, { force: true }).catch(() => {});
log.info("loader.cleanup", { name: manifest.name });
};
return { name: manifest.name, manifest, plugin, cleanup };
}
/**
* Filter environment variables based on permissions.
* Only pass safe env vars unless "env" permission is granted.
*/
function getFilteredEnv(permissions: Permission[]): Record<string, string> {
const safeKeys = ["PATH", "HOME", "USER", "LANG", "LC_ALL", "NODE_ENV"];
const env: Record<string, string> = {};
for (const key of safeKeys) {
if (process.env[key]) {
env[key] = process.env[key]!;
}
}
if (permissions.includes("env")) {
// Pass all env vars
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !safeKeys.includes(key)) {
env[key] = value;
}
}
}
return env;
}