From f377e3842c8ba4be758c0bc39466344fb16dd6cd Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 4 Jul 2026 00:38:32 +0200 Subject: [PATCH] fix(plugins): make the permission-model child load from the real plugin path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prod data dir is a symlink (server/data -> volume), so resolving the plugin under it tripped the permission model, and Node's module-type lookup walked up into the (denied) data dir. Fork the child from the plugin's realpath and drop a {"type":"commonjs"} package.json at its root so resolution stops there — trek.db and the secret files stay unreadable, verified against Koffi. --- server/src/nest/plugins/paths.ts | 33 ++++++++++++++++++- .../plugins/supervisor/plugin-supervisor.ts | 10 ++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/server/src/nest/plugins/paths.ts b/server/src/nest/plugins/paths.ts index 2263a5a4d..d344a8461 100644 --- a/server/src/nest/plugins/paths.ts +++ b/server/src/nest/plugins/paths.ts @@ -27,6 +27,36 @@ export function pluginCodeDir(id: string): string { return path.join(pluginsCodeRoot(), id); } +/** + * The plugin code dir with symlinks resolved. The data root is often a symlink + * (Docker mounts `server/data` -> a volume), and the OS permission model checks + * REAL paths — forking the child from the real path keeps path resolution from + * ever touching the symlinked parent (which would need a broad read grant). + * Falls back to the lexical path if the dir does not exist yet. + */ +export function pluginRealCodeDir(id: string): string { + try { + return fs.realpathSync(pluginCodeDir(id)); + } catch { + return pluginCodeDir(id); + } +} + +/** + * Give a plugin its own `package.json` (only if it ships none) so Node resolves + * the child's module type AT the plugin root and never walks up into the data + * dir — which, under the permission model, is denied (that's the whole point). + * Defaults to CommonJS, matching the plain `module.exports` plugins ship. + */ +export function ensurePluginModuleType(codeDir: string): void { + try { + const pkg = path.join(codeDir, 'package.json'); + if (!fs.existsSync(pkg)) fs.writeFileSync(pkg, '{"type":"commonjs"}\n'); + } catch { + /* best effort — a missing package.json only matters under --permission */ + } +} + /** A plugin's writable data directory (its own sqlite file + any blobs). */ export function pluginDataDir(id: string): string { return path.join(pluginsDataRoot(), id); @@ -90,6 +120,7 @@ export function pluginPermissionArgs(pluginId: string): string[] { // resolve the child's module type. Grant just that file — never its dir, // which also holds the `data` symlink to trek.db and the secret files. `--allow-fs-read=${path.join(codeRoot, '..', 'package.json')}`, - `--allow-fs-read=${pluginCodeDir(pluginId)}`, + // The plugin's own code, by REAL path (see pluginRealCodeDir). + `--allow-fs-read=${pluginRealCodeDir(pluginId)}`, ]; } diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts index c66c7d7de..a49312e27 100644 --- a/server/src/nest/plugins/supervisor/plugin-supervisor.ts +++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { fork, type ChildProcess } from 'node:child_process'; -import { resolveChildEntry, pluginCodeDir, pluginPermissionArgs } from '../paths'; +import { resolveChildEntry, pluginCodeDir, pluginRealCodeDir, pluginPermissionArgs, ensurePluginModuleType } from '../paths'; import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope'; import type { PluginRpcHost } from '../host/rpc-host'; @@ -177,9 +177,13 @@ export class PluginSupervisor { const { entry, execArgv, forkCwd, jsMode } = resolveChildEntry(); // Prod (compiled) children get the OS permission model — a real kernel-level // fs/child_process/native jail on top of the env scrub and RPC boundary. + // Use the plugin's REAL path + ensure it has a package.json so the sandboxed + // child can resolve its own module type without a broad read grant. + const codeDir = jsMode ? pluginRealCodeDir(sup.id) : pluginCodeDir(sup.id); + if (jsMode) ensurePluginModuleType(codeDir); const argv = jsMode ? [...execArgv, ...pluginPermissionArgs(sup.id)] : execArgv; - const child = fork(entry, [sup.id, pluginCodeDir(sup.id)], { - cwd: forkCwd ?? pluginCodeDir(sup.id), + const child = fork(entry, [sup.id, codeDir], { + cwd: forkCwd ?? codeDir, execArgv: argv, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], // Whitelist env — nothing inherited. No JWT_SECRET, no DB creds, no PATH-leaked secrets.