/** * Security tests — filesystem path-safety for plugin manager and loader. * * Covers three fixes: * CRITICAL-2: assertWithinPluginDir called before every rm() in upgrade/uninstall * CRITICAL-3: manifest.main path validated at install/upgrade; staging cleanup on failure * IMPORTANT-6: host script written with flag:"wx" (O_EXCL) + mode:0o600 * * Strategy: * - Behavioral tests where PluginManager is instantiable with a real tmp pluginDir * (uses the same DATA_DIR trick as plugins-edge-cases.test.ts). * - Source-scan assertions for patterns that are hard to trigger behaviorally * (assertWithinPluginDir invocation site, wx/0o600 in loader.ts). */ import test, { describe } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { readFileSync } from "node:fs"; import { resolve as pathResolve } from "node:path"; // ── Temp DB — must be set BEFORE any DB-touching import ────────────────────── const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-fs-safety-")); process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/plugins/../db/core.ts"); const hooks = await import("../../src/lib/plugins/hooks.ts"); const { pluginManager } = await import("../../src/lib/plugins/manager.ts"); // ── Source files for scan-based tests ──────────────────────────────────────── const managerSource = readFileSync( pathResolve(process.cwd(), "src/lib/plugins/manager.ts"), "utf-8" ); const loaderSource = readFileSync( pathResolve(process.cwd(), "src/lib/plugins/loader.ts"), "utf-8" ); // ── Fixture helpers ─────────────────────────────────────────────────────────── /** * Write a minimal valid plugin and return its plugin directory (where plugin.json lives). * * Uses the DIRECT plugin-dir layout — plugin.json at the root of the returned dir. * This exercises the fast-path in install()/upgrade() that reads plugin.json directly * from `sourceDir` without going through scanPluginDir(). That path does NOT call * stat(entryPoint), so our assertEntryPointWithinDest() is the only guard. */ function writePluginWithMain(opts: { name: string; version?: string; main: string; /** If false, skip writing the main file (e.g. for path-escape tests). Default: true for safe paths. */ writeMainFile?: boolean; }): { sourceDir: string } { // sourceDir IS the plugin dir — plugin.json at its root (direct-plugin-dir layout) const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), `plugin-fs-safety-${opts.name}-`)); fs.writeFileSync( path.join(sourceDir, "plugin.json"), JSON.stringify({ name: opts.name, version: opts.version ?? "1.0.0", description: "FS-safety test plugin", author: "test", main: opts.main, hooks: { onRequest: false, onResponse: false, onError: false }, enabledByDefault: false, requires: { permissions: [] }, }) ); // Write the main file only for safe relative paths const shouldWrite = opts.writeMainFile !== false && !opts.main.startsWith("..") && !path.isAbsolute(opts.main); if (shouldWrite) { const mainAbs = path.join(sourceDir, opts.main); fs.mkdirSync(path.dirname(mainAbs), { recursive: true }); fs.writeFileSync(mainAbs, "module.exports = {};"); } return { sourceDir }; } const activeDirs: string[] = []; // The manager writes installed plugins to getDefaultPluginDir() = ~/.omniroute/plugins/. // We must clean those dirs between tests to avoid ENOTEMPTY / stale state. const DEFAULT_PLUGIN_DIR = path.join( process.env.HOME || process.env.USERPROFILE || "/tmp", ".omniroute", "plugins" ); /** Known plugin names created by this test file — cleaned between tests. */ const MANAGED_PLUGIN_NAMES = [ "escape-install", "escape-abs", "escape-deep", "escape-cleanup", "escape-upgrade", "valid-regression", "valid-upgrade-regression", ]; function cleanInstalledPluginDirs() { for (const name of MANAGED_PLUGIN_NAMES) { // Remove final dir and any staging remnants const base = path.join(DEFAULT_PLUGIN_DIR, name); try { fs.rmSync(base, { recursive: true, force: true }); } catch {} // Also clean any .staging-* leftovers if (fs.existsSync(DEFAULT_PLUGIN_DIR)) { for (const entry of fs.readdirSync(DEFAULT_PLUGIN_DIR)) { if (entry.startsWith(`${name}.staging-`)) { try { fs.rmSync(path.join(DEFAULT_PLUGIN_DIR, entry), { recursive: true, force: true }); } catch {} } } } } } function cleanSourceDirs() { for (const d of activeDirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } activeDirs.length = 0; } test.beforeEach(() => { core.resetDbInstance(); hooks.resetHooks(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); cleanSourceDirs(); cleanInstalledPluginDirs(); }); test.after(() => { core.resetDbInstance(); cleanSourceDirs(); cleanInstalledPluginDirs(); try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {} }); // ══════════════════════════════════════════════════════════════════════════════ // CRITICAL-3 — behavioral: manifest.main path traversal rejected at install time // ══════════════════════════════════════════════════════════════════════════════ test("install rejects manifest.main with path traversal (../../escape.js)", async () => { // Uses direct-plugin-dir layout so install() reads plugin.json directly (fast path), // bypassing scanPluginDir's stat() check — only assertEntryPointWithinDest() stops it. const { sourceDir } = writePluginWithMain({ name: "escape-install", main: "../../escape.js", writeMainFile: false, }); activeDirs.push(sourceDir); await assert.rejects( () => pluginManager.install(sourceDir), (err: Error) => { // Must mention escaping/outside, not just a generic FS error assert.ok( err.message.includes("escapes") || err.message.includes("outside") || err.message.includes("Refusing"), `Expected containment error, got: ${err.message}` ); return true; } ); // No partial install dir or staging dir should be left behind const pluginsRoot = path.join(TEST_DATA_DIR, "plugins"); if (fs.existsSync(pluginsRoot)) { const entries = fs.readdirSync(pluginsRoot); const leftover = entries.filter((e) => e.startsWith("escape-install")); assert.deepEqual( leftover, [], `Staging/install dir left behind after failed install: ${leftover.join(", ")}` ); } }); test("install rejects manifest.main with deep traversal escape (../../../evil.js)", async () => { // A deeper traversal that definitely escapes the staging dir const { sourceDir } = writePluginWithMain({ name: "escape-deep", main: "../../../../../../../evil.js", writeMainFile: false, }); activeDirs.push(sourceDir); await assert.rejects( () => pluginManager.install(sourceDir), (err: Error) => { assert.ok( err.message.includes("escapes") || err.message.includes("outside") || err.message.includes("Refusing"), `Expected containment error, got: ${err.message}` ); return true; } ); // No staging residue const pluginsRoot = path.join(TEST_DATA_DIR, "plugins"); if (fs.existsSync(pluginsRoot)) { const entries = fs.readdirSync(pluginsRoot); const leftover = entries.filter((e) => e.startsWith("escape-deep")); assert.deepEqual(leftover, [], `Leftover dirs after failed install: ${leftover.join(", ")}`); } }); test("install does NOT leave a staging dir behind on manifest.main escape", async () => { const { sourceDir } = writePluginWithMain({ name: "escape-cleanup", main: "../../../tmp/evil.js", writeMainFile: false, }); activeDirs.push(sourceDir); await assert.rejects(() => pluginManager.install(sourceDir)); const pluginsRoot = path.join(TEST_DATA_DIR, "plugins"); if (fs.existsSync(pluginsRoot)) { const entries = fs.readdirSync(pluginsRoot); const leftovers = entries.filter((e) => e.includes("escape-cleanup")); assert.deepEqual(leftovers, [], `Found leftover dirs: ${leftovers.join(", ")}`); } }); // ══════════════════════════════════════════════════════════════════════════════ // CRITICAL-3 — behavioral: upgrade rejects manifest.main escape + no staging residue // ══════════════════════════════════════════════════════════════════════════════ test("upgrade rejects manifest.main with path traversal and leaves old install intact", async () => { // Install valid v1 using the direct-plugin-dir layout const { sourceDir: v1Dir } = writePluginWithMain({ name: "escape-upgrade", version: "1.0.0", main: "index.js", }); activeDirs.push(v1Dir); await pluginManager.install(v1Dir); // Attempt upgrade with v2 that has a malicious main (direct-plugin-dir layout, // bypassing scanPluginDir's stat() check so only our containment guard fires) const { sourceDir: v2Dir } = writePluginWithMain({ name: "escape-upgrade", version: "2.0.0", main: "../../evil.js", writeMainFile: false, }); activeDirs.push(v2Dir); await assert.rejects( () => pluginManager.upgrade(v2Dir), (err: Error) => { assert.ok( err.message.includes("escapes") || err.message.includes("outside") || err.message.includes("Refusing"), `Expected containment error, got: ${err.message}` ); return true; } ); // Old v1 install should still be in DB (upgrade rolled back before deleting old dir) const row = pluginManager.getPlugin("escape-upgrade"); assert.ok(row, "Old plugin record should still exist after failed upgrade"); assert.equal(row.version, "1.0.0", "Old version should be preserved"); await pluginManager.uninstall("escape-upgrade"); }); // ══════════════════════════════════════════════════════════════════════════════ // CRITICAL-2 — source-scan: assertWithinPluginDir called before each rm() // ══════════════════════════════════════════════════════════════════════════════ test("source: assertWithinPluginDir helper is defined in manager.ts", () => { assert.ok( managerSource.includes("assertWithinPluginDir"), "assertWithinPluginDir must be defined in manager.ts" ); }); test("source: assertWithinPluginDir is called before rm in uninstall", () => { // Find the uninstall method body and check containment guard precedes rm call const uninstallIdx = managerSource.indexOf("async uninstall("); assert.ok(uninstallIdx !== -1, "uninstall method not found"); // Get the slice from uninstall through the next method const afterUninstall = managerSource.slice(uninstallIdx); const nextMethodIdx = afterUninstall.indexOf("\n async ", 10); const uninstallBody = nextMethodIdx !== -1 ? afterUninstall.slice(0, nextMethodIdx) : afterUninstall; const guardIdx = uninstallBody.indexOf("assertWithinPluginDir"); const rmIdx = uninstallBody.indexOf("await rm("); assert.ok(guardIdx !== -1, "assertWithinPluginDir must be called in uninstall"); assert.ok(rmIdx !== -1, "rm() must be called in uninstall"); assert.ok( guardIdx < rmIdx, `assertWithinPluginDir (pos ${guardIdx}) must appear before rm() (pos ${rmIdx}) in uninstall` ); }); test("source: assertWithinPluginDir is called before rm in upgrade", () => { const upgradeIdx = managerSource.indexOf("async upgrade("); assert.ok(upgradeIdx !== -1, "upgrade method not found"); const afterUpgrade = managerSource.slice(upgradeIdx); const nextMethodIdx = afterUpgrade.indexOf("\n async activate("); const upgradeBody = nextMethodIdx !== -1 ? afterUpgrade.slice(0, nextMethodIdx) : afterUpgrade; const guardIdx = upgradeBody.indexOf("assertWithinPluginDir"); const rmIdx = upgradeBody.indexOf("await rm("); assert.ok(guardIdx !== -1, "assertWithinPluginDir must be called in upgrade"); assert.ok(rmIdx !== -1, "rm() must be called in upgrade"); assert.ok( guardIdx < rmIdx, `assertWithinPluginDir (pos ${guardIdx}) must appear before rm() (pos ${rmIdx}) in upgrade` ); }); test("source: assertWithinPluginDir throws for path outside pluginDir", () => { // Extract and evaluate the helper via string match to verify logic is correct. // We test the behavioral contract: resolve("/plugins/foo") is fine, // resolve("/tmp/evil") is not fine when root is "/plugins". // Since we can't easily import the unexported helper, verify it uses resolve + sep. assert.ok( managerSource.includes('resolve(pluginRoot)') || managerSource.includes('resolve(this_pluginDir)') || managerSource.includes('resolve('), "assertWithinPluginDir must call resolve()" ); assert.ok( managerSource.includes("sep"), "assertWithinPluginDir must use path.sep for boundary check" ); assert.ok( managerSource.includes("Refusing to delete"), "assertWithinPluginDir must throw with 'Refusing to delete' message" ); }); // ══════════════════════════════════════════════════════════════════════════════ // CRITICAL-3 — source-scan: assertEntryPointWithinDest called before DB insert // ══════════════════════════════════════════════════════════════════════════════ test("source: assertEntryPointWithinDest helper is defined in manager.ts", () => { assert.ok( managerSource.includes("assertEntryPointWithinDest"), "assertEntryPointWithinDest must be defined in manager.ts" ); }); test("source: assertEntryPointWithinDest is called in install before insertPlugin", () => { const installIdx = managerSource.indexOf("async install("); assert.ok(installIdx !== -1, "install method not found"); const afterInstall = managerSource.slice(installIdx); const nextMethodIdx = afterInstall.indexOf("\n async upgrade("); const installBody = nextMethodIdx !== -1 ? afterInstall.slice(0, nextMethodIdx) : afterInstall; const guardIdx = installBody.indexOf("assertEntryPointWithinDest"); const insertIdx = installBody.indexOf("insertPlugin("); assert.ok(guardIdx !== -1, "assertEntryPointWithinDest must be called in install"); assert.ok(insertIdx !== -1, "insertPlugin must be called in install"); assert.ok( guardIdx < insertIdx, `assertEntryPointWithinDest (pos ${guardIdx}) must appear before insertPlugin (pos ${insertIdx}) in install` ); }); test("source: assertEntryPointWithinDest is called in upgrade before insertPlugin", () => { const upgradeIdx = managerSource.indexOf("async upgrade("); assert.ok(upgradeIdx !== -1, "upgrade method not found"); const afterUpgrade = managerSource.slice(upgradeIdx); const nextMethodIdx = afterUpgrade.indexOf("\n async activate("); const upgradeBody = nextMethodIdx !== -1 ? afterUpgrade.slice(0, nextMethodIdx) : afterUpgrade; const guardIdx = upgradeBody.indexOf("assertEntryPointWithinDest"); const insertIdx = upgradeBody.indexOf("insertPlugin("); assert.ok(guardIdx !== -1, "assertEntryPointWithinDest must be called in upgrade"); assert.ok(insertIdx !== -1, "insertPlugin must be called in upgrade"); assert.ok( guardIdx < insertIdx, `assertEntryPointWithinDest (pos ${guardIdx}) must appear before insertPlugin (pos ${insertIdx}) in upgrade` ); }); // ══════════════════════════════════════════════════════════════════════════════ // CRITICAL-3 — source-scan: atomic staging pattern (staging dir + rename) // ══════════════════════════════════════════════════════════════════════════════ test("source: install uses atomic staging rename pattern", () => { assert.ok( managerSource.includes(".staging-"), "install must use a staging dir with .staging- suffix" ); assert.ok( managerSource.includes("rename(stagingDir"), "install must atomically rename staging dir to final dest" ); }); test("source: install cleans up staging dir on failure (rm in catch)", () => { const installIdx = managerSource.indexOf("async install("); const afterInstall = managerSource.slice(installIdx); const nextMethodIdx = afterInstall.indexOf("\n async upgrade("); const installBody = nextMethodIdx !== -1 ? afterInstall.slice(0, nextMethodIdx) : afterInstall; // There must be a rm(stagingDir) inside a catch block assert.ok( installBody.includes("rm(stagingDir"), "install must rm(stagingDir) in the catch/failure path" ); }); // ══════════════════════════════════════════════════════════════════════════════ // IMPORTANT-6 — source-scan: host script written with flag:"wx" + mode:0o600 // ══════════════════════════════════════════════════════════════════════════════ test('source: loader uses flag:"wx" (O_EXCL) when writing host script', () => { assert.ok( loaderSource.includes('"wx"') || loaderSource.includes("'wx'"), 'loader.ts must use flag: "wx" (O_EXCL) for the host script writeFile' ); }); test("source: loader uses mode:0o600 when writing host script", () => { assert.ok( loaderSource.includes("0o600"), "loader.ts must use mode: 0o600 for the host script writeFile" ); }); test("source: loader retries on EEXIST collision when writing host script", () => { assert.ok( loaderSource.includes("EEXIST"), "loader.ts must handle EEXIST on O_EXCL write (retry once with fresh UUID)" ); }); // ══════════════════════════════════════════════════════════════════════════════ // Regression: valid plugins still install and uninstall cleanly // ══════════════════════════════════════════════════════════════════════════════ test("install and uninstall work for a valid plugin (regression)", async () => { // Direct-plugin-dir layout: sourceDir IS the plugin dir (plugin.json at root) const { sourceDir } = writePluginWithMain({ name: "valid-regression", main: "index.js", }); activeDirs.push(sourceDir); const row = await pluginManager.install(sourceDir); assert.equal(row.name, "valid-regression"); assert.equal(row.version, "1.0.0"); await pluginManager.uninstall("valid-regression"); assert.equal(pluginManager.getPlugin("valid-regression"), null); }); test("upgrade works for a valid newer version (regression)", async () => { const { sourceDir: v1 } = writePluginWithMain({ name: "valid-upgrade-regression", version: "1.0.0", main: "index.js", }); activeDirs.push(v1); await pluginManager.install(v1); const { sourceDir: v2 } = writePluginWithMain({ name: "valid-upgrade-regression", version: "2.0.0", main: "index.js", }); activeDirs.push(v2); const row = await pluginManager.upgrade(v2); assert.equal(row.version, "2.0.0"); await pluginManager.uninstall("valid-upgrade-regression"); });