diff --git a/packages/utils/README.md b/packages/utils/README.md index b0f8722..e07a1a2 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -11,3 +11,21 @@ const { tmpPath } = await cloneTemplate({ url: GIT_URL }); `cloneTemplate` requires Git 2.37 or newer. It clones the template into a temporary sparse checkout and removes that directory when the Node process exits. + +## Safe scripts + +`spawnSafe` requires Node.js 26 or newer and runs JavaScript or TypeScript with +Node's permission model enabled. Every `defaultFSPaths` entry is normalized to an +absolute path and granted read/write access. Other permissions remain disabled +unless explicitly allowed. The `allows` option supports `"net"`, `"child"`, +`"worker"`, `"addons"`, `"wasi"`, and `"ffi"`. + +```ts +import { spawnSafe } from "@tempblot/utils"; + +const { new_child_process } = spawnSafe({ + file: "./generate.ts", + defaultFSPaths: ["./input", "./output"], + allows: { net: true }, +}); +``` diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5384885..e7b9ad6 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,8 +1,9 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import * as fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; const minimumGitVersion = [2, 37, 0] as const; const cleanupPaths = new Set(); @@ -15,6 +16,25 @@ export interface CloneTemplateOptions { export interface CloneTemplateResult { tmpPath: string; } + +export type SpawnSafeAllow = + | "addons" + | "child" + | "ffi" + | "net" + | "wasi" + | "worker"; + +export type SpawnSafeAllows = Partial>; + +export interface SpawnSafeOptions { + allows?: SpawnSafeAllows; + defaultFSPaths?: readonly (string | URL)[]; + file: string | URL; +} + +export interface SpawnSafeResult { + new_child_process: ChildProcessWithoutNullStreams; } export async function cloneTemplate( @@ -54,6 +74,84 @@ export async function cloneTemplate( registerCleanup(clonePath); return { tmpPath: clonePath }; } + +export function spawnSafe(options: SpawnSafeOptions): SpawnSafeResult { + ensureSupportedNodeVersion(); + + const normalizedFilePath = normalizeFilePath(options.file); + const extension = path.extname(normalizedFilePath); + + if (extension !== ".js" && extension !== ".ts") { + throw new TypeError( + `spawnSafe only supports .js and .ts files: ${String(options.file)}`, + ); + } + + const defaultFSPaths = (options.defaultFSPaths ?? []).map(normalizeFilePath); + const nodeArguments = [ + "--permission", + `--allow-fs-read=${normalizedFilePath}`, + ]; + + for (const defaultFSPath of defaultFSPaths) { + nodeArguments.push( + `--allow-fs-read=${defaultFSPath}`, + `--allow-fs-write=${defaultFSPath}`, + ); + } + + nodeArguments.push(...getAllowFlags(options.allows ?? {})); + nodeArguments.push(normalizedFilePath); + + return { new_child_process: spawn(process.execPath, nodeArguments) }; +} + +const permissionFlags: Readonly> = { + addons: "--allow-addons", + child: "--allow-child-process", + ffi: "--allow-ffi", + net: "--allow-net", + wasi: "--allow-wasi", + worker: "--allow-worker", +}; + +function getAllowFlags(allows: SpawnSafeAllows): string[] { + const flags: string[] = []; + + for (const [allow, enabled] of Object.entries(allows)) { + const flag = (permissionFlags as Readonly>)[allow]; + + if (flag === undefined) { + throw new TypeError(`Unknown spawnSafe permission: ${allow}`); + } + + if (typeof enabled !== "boolean") { + throw new TypeError(`spawnSafe permission ${allow} must be a boolean`); + } + + if (enabled) { + flags.push(flag); + } + } + + return flags; +} + +function ensureSupportedNodeVersion(): void { + const [majorVersion = ""] = process.versions.node.split("."); + const major = Number.parseInt(majorVersion, 10); + + if (!Number.isSafeInteger(major) || major < 26) { + throw new Error( + `spawnSafe requires Node.js 26 or newer; found ${process.versions.node}.`, + ); + } +} + +function normalizeFilePath(filePath: string | URL): string { + return filePath instanceof URL + ? fileURLToPath(filePath) + : path.resolve(filePath); } async function ensureSupportedGitVersion(): Promise { diff --git a/packages/utils/tests/index.spec.ts b/packages/utils/tests/index.spec.ts index 0ce5174..0da05ac 100644 --- a/packages/utils/tests/index.spec.ts +++ b/packages/utils/tests/index.spec.ts @@ -6,7 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, expect, test } from "vitest"; -import { cloneTemplate } from "../src/index.ts"; +import { cloneTemplate, spawnSafe } from "../src/index.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const sourceUrl = pathToFileURL(path.join(__dirname, "../src/index.ts")).href; @@ -93,6 +93,119 @@ test("removes the clone when the process exits", async () => { await expect(fs.access(clonePath)).rejects.toThrow(); }); +test("requires Node.js 26 or newer for safe processes", () => { + withNodeRuntime("25.9.0", process.execPath, () => { + expect(() => spawnSafe({ file: "script.ts" })).toThrow( + "spawnSafe requires Node.js 26 or newer; found 25.9.0.", + ); + }); +}); + +test("passes restricted permission flags to Node", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tempblot-utils-safe-")); + const executablePath = path.join(root, "node"); + const argumentLogPath = path.join(root, "arguments.json"); + const scriptPath = path.join(root, "script.ts"); + const allowedPath = path.join(root, "allowed"); + testRoots.push(root); + + await fs.writeFile( + executablePath, + `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(argumentLogPath)}, JSON.stringify(process.argv.slice(2)));\n`, + ); + await fs.chmod(executablePath, 0o755); + await fs.writeFile(scriptPath, ""); + + const { new_child_process } = withNodeRuntime("26.0.0", executablePath, () => + spawnSafe({ + file: scriptPath, + defaultFSPaths: [allowedPath], + allows: { + net: true, + child: true, + worker: true, + addons: true, + wasi: true, + ffi: true, + }, + }), + ); + const result = await collectProcess(new_child_process); + + expect(result.stderr).toBe(""); + expect(result.code).toBe(0); + await expect(fs.readFile(argumentLogPath, "utf8")).resolves.toBe( + JSON.stringify([ + "--permission", + `--allow-fs-read=${scriptPath}`, + `--allow-fs-read=${allowedPath}`, + `--allow-fs-write=${allowedPath}`, + "--allow-net", + "--allow-child-process", + "--allow-worker", + "--allow-addons", + "--allow-wasi", + "--allow-ffi", + scriptPath, + ]), + ); +}); + +test("allows requested files and denies other permissions by default", async () => { + const root = await fs.mkdtemp(path.join(__dirname, "tempblot-utils-safe-")); + const scriptPath = path.join(root, "script.ts"); + const allowedPath = path.join(root, "allowed.txt"); + const blockedPath = path.join(root, "blocked.txt"); + testRoots.push(root); + + await fs.writeFile( + scriptPath, + ` + import { writeFileSync } from "node:fs"; + import { spawnSync } from "node:child_process"; + import { Worker } from "node:worker_threads"; + + writeFileSync(${JSON.stringify(allowedPath)}, "allowed"); + + let fileSystemError; + let childProcessError; + let workerError; + try { + writeFileSync(${JSON.stringify(blockedPath)}, "blocked"); + } catch (error) { + fileSystemError = error.code; + } + try { + spawnSync(process.execPath, ["--version"]); + } catch (error) { + childProcessError = error.code; + } + try { + new Worker("", { eval: true }); + } catch (error) { + workerError = error.code; + } + + console.log(JSON.stringify({ fileSystemError, childProcessError, workerError })); + `, + ); + + const { new_child_process } = withNodeRuntime("26.0.0", process.execPath, () => + spawnSafe({ file: scriptPath, defaultFSPaths: [allowedPath] }), + ); + const result = await collectProcess(new_child_process); + + expect(result.stderr).toBe(""); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + fileSystemError: "ERR_ACCESS_DENIED", + childProcessError: "ERR_ACCESS_DENIED", + workerError: "ERR_ACCESS_DENIED", + }); + await expect(fs.readFile(allowedPath, "utf8")).resolves.toBe("allowed"); + await expect(fs.access(blockedPath)).rejects.toThrow(); +}); + async function createRepository(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tempblot-utils-repo-")); const repositoryPath = path.join(root, "repository"); @@ -148,6 +261,57 @@ interface ProcessResult { stderr: string; } +function withNodeRuntime( + version: string, + execPath: string, + callback: () => TResult, +): TResult { + const versionDescriptor = Object.getOwnPropertyDescriptor( + process.versions, + "node", + ); + const originalExecPath = process.execPath; + + Object.defineProperty(process.versions, "node", { + configurable: true, + enumerable: true, + value: version, + }); + process.execPath = execPath; + + try { + return callback(); + } finally { + process.execPath = originalExecPath; + + if (versionDescriptor !== undefined) { + Object.defineProperty(process.versions, "node", versionDescriptor); + } + } +} + +function collectProcess( + child: ReturnType["new_child_process"], +): Promise { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + function runProcess( command: string, arguments_: string[],