Prefer non-user writeable paths (#54346)

* infra: trust system binary roots

* infra: isolate windows install root overrides

* infra: narrow windows reg lookup

* browser: restore windows executable comments

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
This commit is contained in:
Anmol Ahuja
2026-03-27 04:29:32 -07:00
committed by GitHub
parent 9d58f9e24f
commit c40884d306
12 changed files with 1121 additions and 36 deletions

View File

@@ -574,9 +574,8 @@ export function findGoogleChromeExecutableLinux(): BrowserExecutable | null {
export function findChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
// Must use bracket notation: variable name contains parentheses
// Must use bracket notation: variable name contains parentheses.
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: Array<BrowserExecutable> = [];

View File

@@ -254,4 +254,40 @@ describe("resolveSystemNodeInfo", () => {
expect(warning).toContain("below the required Node 22.14+");
expect(warning).toContain(darwinNode);
});
it("uses validated custom Program Files roots on Windows", async () => {
const customNode = "D:\\Programs\\nodejs\\node.exe";
mockNodePathPresent(customNode);
const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" });
const result = await resolveSystemNodeInfo({
env: {
ProgramFiles: "D:\\Programs",
"ProgramFiles(x86)": "E:\\Programs (x86)",
},
platform: "win32",
execFile,
});
expect(result?.path).toBe(customNode);
});
it("prefers ProgramW6432 over ProgramFiles on Windows", async () => {
const preferredNode = "D:\\Programs\\nodejs\\node.exe";
const x86Node = "E:\\Programs (x86)\\nodejs\\node.exe";
mockNodePathPresent(preferredNode, x86Node);
const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" });
const result = await resolveSystemNodeInfo({
env: {
ProgramFiles: "E:\\Programs (x86)",
"ProgramFiles(x86)": "E:\\Programs (x86)",
ProgramW6432: "D:\\Programs",
},
platform: "win32",
execFile,
});
expect(result?.path).toBe(preferredNode);
});
});

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { promisify } from "node:util";
import { isSupportedNodeVersion } from "../infra/runtime-guard.js";
import { resolveStableNodePath } from "../infra/stable-node-path.js";
import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js";
const VERSION_MANAGER_MARKERS = [
"/.nvm/",
@@ -47,12 +48,9 @@ function buildSystemNodeCandidates(
}
if (platform === "win32") {
const pathModule = getPathModule(platform);
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
return [
pathModule.join(programFiles, "nodejs", "node.exe"),
pathModule.join(programFilesX86, "nodejs", "node.exe"),
];
return getWindowsProgramFilesRoots(env).map((root) =>
pathModule.join(root, "nodejs", "node.exe"),
);
}
return [];
}

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { isGatewayArgv } from "../infra/gateway-process-argv.js";
import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js";
import { inspectPortUsage } from "../infra/ports.js";
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
import { killProcessTree } from "../process/kill-tree.js";
import { sleep } from "../utils.js";
import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js";
@@ -438,11 +439,7 @@ async function terminateGatewayProcessTree(pid: number, graceMs: number): Promis
killProcessTree(pid, { graceMs });
return;
}
const taskkillPath = path.join(
process.env.SystemRoot ?? "C:\\Windows",
"System32",
"taskkill.exe",
);
const taskkillPath = path.join(getWindowsInstallRoots().systemRoot, "System32", "taskkill.exe");
spawnSync(taskkillPath, ["/T", "/PID", String(pid)], {
stdio: "ignore",
timeout: 5_000,

View File

@@ -123,8 +123,8 @@ describe("ensureOpenClawCliOnPath", () => {
expect(process.env.PATH).toBe("/bin");
});
it("prepends mise shims when available", () => {
const { tmp, appBinDir, appCli } = setupAppCliRoot("case-mise");
it("appends mise shims after system dirs", () => {
const { tmp, appCli } = setupAppCliRoot("case-mise");
const miseDataDir = path.join(tmp, "mise");
const shimsDir = path.join(miseDataDir, "shims");
setDir(miseDataDir);
@@ -140,10 +140,10 @@ describe("ensureOpenClawCliOnPath", () => {
homeDir: tmp,
platform: "darwin",
});
const appBinIndex = updated.indexOf(appBinDir);
const usrBinIndex = updated.indexOf("/usr/bin");
const shimsIndex = updated.indexOf(shimsDir);
expect(appBinIndex).toBeGreaterThanOrEqual(0);
expect(shimsIndex).toBeGreaterThan(appBinIndex);
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(shimsIndex).toBeGreaterThan(usrBinIndex);
});
it.each([
@@ -222,7 +222,85 @@ describe("ensureOpenClawCliOnPath", () => {
expect(updated.indexOf(xdgBinHome)).toBeLessThan(updated.indexOf(localBin));
});
it("prepends Linuxbrew dirs when present", () => {
it("places ~/.local/bin AFTER /usr/bin to prevent PATH hijack", () => {
const { tmp, appCli } = setupAppCliRoot("case-path-hijack");
const localBin = path.join(tmp, ".local", "bin");
setDir(path.join(tmp, ".local"));
setDir(localBin);
process.env.PATH = "/usr/bin:/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
delete process.env.XDG_BIN_HOME;
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
const usrBinIndex = updated.indexOf("/usr/bin");
const localBinIndex = updated.indexOf(localBin);
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(localBinIndex).toBeGreaterThanOrEqual(0);
expect(localBinIndex).toBeGreaterThan(usrBinIndex);
});
it("places all user-writable home dirs after system dirs", () => {
const { tmp, appCli } = setupAppCliRoot("case-user-writable-after-system");
const localBin = path.join(tmp, ".local", "bin");
const pnpmBin = path.join(tmp, ".local", "share", "pnpm");
const bunBin = path.join(tmp, ".bun", "bin");
const yarnBin = path.join(tmp, ".yarn", "bin");
setDir(path.join(tmp, ".local"));
setDir(localBin);
setDir(path.join(tmp, ".local", "share"));
setDir(pnpmBin);
setDir(path.join(tmp, ".bun"));
setDir(bunBin);
setDir(path.join(tmp, ".yarn"));
setDir(yarnBin);
process.env.PATH = "/usr/bin:/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
delete process.env.XDG_BIN_HOME;
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
const usrBinIndex = updated.indexOf("/usr/bin");
for (const userDir of [localBin, pnpmBin, bunBin, yarnBin]) {
const idx = updated.indexOf(userDir);
expect(idx, `${userDir} should come after /usr/bin`).toBeGreaterThan(usrBinIndex);
}
});
it("appends Homebrew dirs after immutable OS dirs", () => {
const { tmp, appCli } = setupAppCliRoot("case-homebrew-after-system");
setDir("/opt/homebrew/bin");
setDir("/usr/local/bin");
process.env.PATH = "/usr/bin:/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
delete process.env.HOMEBREW_PREFIX;
delete process.env.HOMEBREW_BREW_FILE;
delete process.env.XDG_BIN_HOME;
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const usrBinIndex = updated.indexOf("/usr/bin");
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(updated.indexOf("/opt/homebrew/bin")).toBeGreaterThan(usrBinIndex);
expect(updated.indexOf("/usr/local/bin")).toBeGreaterThan(usrBinIndex);
});
it("appends Linuxbrew dirs after system dirs", () => {
const tmp = abs("/tmp/openclaw-path/case-linuxbrew");
const execDir = path.join(tmp, "exec");
setDir(tmp);
@@ -247,7 +325,9 @@ describe("ensureOpenClawCliOnPath", () => {
homeDir: tmp,
platform: "linux",
});
expect(parts[0]).toBe(linuxbrewBin);
expect(parts[1]).toBe(linuxbrewSbin);
const usrBinIndex = parts.indexOf("/usr/bin");
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(parts.indexOf(linuxbrewBin)).toBeGreaterThan(usrBinIndex);
expect(parts.indexOf(linuxbrewSbin)).toBeGreaterThan(usrBinIndex);
});
});

View File

@@ -81,26 +81,30 @@ function candidateBinDirs(opts: EnsureOpenClawPathOpts): { prepend: string[]; ap
}
}
// Only immutable OS directories go in prepend so they take priority over
// user-writable locations, preventing PATH hijack of system binaries.
prepend.push("/usr/bin", "/bin");
// User-writable / package-manager directories are appended so they never
// shadow trusted OS binaries.
// This includes Brew/Homebrew dirs, which are useful for finding `openclaw`
// in launchd/minimal environments but must not be treated as trusted.
append.push(...resolveBrewPathDirs({ homeDir }));
const miseDataDir = process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
const miseShims = path.join(miseDataDir, "shims");
if (isDirectory(miseShims)) {
prepend.push(miseShims);
append.push(miseShims);
}
prepend.push(...resolveBrewPathDirs({ homeDir }));
// Common global install locations (macOS first).
if (platform === "darwin") {
prepend.push(path.join(homeDir, "Library", "pnpm"));
append.push(path.join(homeDir, "Library", "pnpm"));
}
if (process.env.XDG_BIN_HOME) {
prepend.push(process.env.XDG_BIN_HOME);
append.push(process.env.XDG_BIN_HOME);
}
prepend.push(path.join(homeDir, ".local", "bin"));
prepend.push(path.join(homeDir, ".local", "share", "pnpm"));
prepend.push(path.join(homeDir, ".bun", "bin"));
prepend.push(path.join(homeDir, ".yarn", "bin"));
prepend.push("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin");
append.push(path.join(homeDir, ".local", "bin"));
append.push(path.join(homeDir, ".local", "share", "pnpm"));
append.push(path.join(homeDir, ".bun", "bin"));
append.push(path.join(homeDir, ".yarn", "bin"));
return { prepend: prepend.filter(isDirectory), append: append.filter(isDirectory) };
}

View File

@@ -0,0 +1,303 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { _getTrustedDirs, _resetResolveSystemBin, resolveSystemBin } from "./resolve-system-bin.js";
import {
_resetWindowsInstallRootsForTests,
getWindowsInstallRoots,
getWindowsProgramFilesRoots,
} from "./windows-install-roots.js";
let executables: Set<string>;
beforeEach(() => {
executables = new Set<string>();
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
});
afterEach(() => {
_resetResolveSystemBin();
_resetWindowsInstallRootsForTests();
});
describe("resolveSystemBin", () => {
it("returns null when binary is not in any trusted directory", () => {
expect(resolveSystemBin("nonexistent")).toBeNull();
});
if (process.platform !== "win32") {
it("resolves a binary found in /usr/bin", () => {
executables.add("/usr/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg");
});
it("does NOT resolve a binary found in /usr/local/bin with strict trust", () => {
executables.add("/usr/local/bin/openssl");
expect(resolveSystemBin("openssl")).toBeNull();
expect(resolveSystemBin("openssl", { trust: "strict" })).toBeNull();
});
it("does NOT resolve a binary found in /opt/homebrew/bin with strict trust", () => {
executables.add("/opt/homebrew/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBeNull();
expect(resolveSystemBin("ffmpeg", { trust: "strict" })).toBeNull();
});
it("does NOT resolve a binary from a user-writable directory like ~/.local/bin", () => {
executables.add("/home/testuser/.local/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBeNull();
});
it("prefers /usr/bin over /usr/local/bin (first match wins)", () => {
executables.add("/usr/bin/openssl");
executables.add("/usr/local/bin/openssl");
expect(resolveSystemBin("openssl")).toBe("/usr/bin/openssl");
});
it("caches results across calls", () => {
executables.add("/usr/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg");
executables.delete("/usr/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg");
});
it("supports extraDirs for caller-specific paths", () => {
const customDir = "/custom/system/bin";
executables.add(`${customDir}/mytool`);
expect(resolveSystemBin("mytool", { extraDirs: [customDir] })).toBe(`${customDir}/mytool`);
});
it("extraDirs results do not poison the cache for callers without extraDirs", () => {
const untrustedDir = "/home/user/.local/bin";
executables.add(`${untrustedDir}/ffmpeg`);
expect(resolveSystemBin("ffmpeg", { extraDirs: [untrustedDir] })).toBe(
`${untrustedDir}/ffmpeg`,
);
expect(resolveSystemBin("ffmpeg")).toBeNull();
});
}
if (process.platform === "darwin") {
it("resolves a binary in /opt/homebrew/bin with standard trust on macOS", () => {
executables.add("/opt/homebrew/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/opt/homebrew/bin/ffmpeg");
});
it("resolves a binary in /usr/local/bin with standard trust on macOS", () => {
executables.add("/usr/local/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg");
});
it("prefers /usr/bin over /opt/homebrew/bin with standard trust", () => {
executables.add("/usr/bin/ffmpeg");
executables.add("/opt/homebrew/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/bin/ffmpeg");
});
it("standard trust results do not poison the strict cache", () => {
executables.add("/opt/homebrew/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/opt/homebrew/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg")).toBeNull();
});
it("extraDirs composes with standard trust", () => {
const customDir = "/opt/custom/bin";
executables.add(`${customDir}/mytool`);
expect(resolveSystemBin("mytool", { trust: "standard", extraDirs: [customDir] })).toBe(
`${customDir}/mytool`,
);
});
}
if (process.platform === "linux") {
it("resolves a binary in /usr/local/bin with standard trust on Linux", () => {
executables.add("/usr/local/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg");
});
it("prefers /usr/bin over /usr/local/bin with standard trust on Linux", () => {
executables.add("/usr/bin/ffmpeg");
executables.add("/usr/local/bin/ffmpeg");
expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/bin/ffmpeg");
});
}
});
describe("trusted directory list", () => {
it("never includes user-writable home directories", () => {
const dirs = _getTrustedDirs();
for (const dir of dirs) {
expect(dir, `${dir} should not be user-writable`).not.toMatch(/\.(local|bun|yarn)/);
expect(dir, `${dir} should not be a pnpm dir`).not.toContain("pnpm");
}
});
if (process.platform !== "win32") {
it("includes base Unix system directories only", () => {
const dirs = _getTrustedDirs();
expect(dirs).toContain("/usr/bin");
expect(dirs).toContain("/bin");
expect(dirs).toContain("/usr/sbin");
expect(dirs).toContain("/sbin");
expect(dirs).not.toContain("/usr/local/bin");
});
it("ignores env-controlled NIX_PROFILES entries, including direct store paths", () => {
const saved = process.env.NIX_PROFILES;
try {
process.env.NIX_PROFILES =
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1 /tmp/evil /home/user/.nix-profile /nix/var/nix/profiles/default";
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
const dirs = _getTrustedDirs();
expect(dirs).not.toContain("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin");
expect(dirs).not.toContain("/tmp/evil/bin");
expect(dirs).not.toContain("/home/user/.nix-profile/bin");
expect(dirs).not.toContain("/nix/var/nix/profiles/default/bin");
} finally {
if (saved === undefined) {
delete process.env.NIX_PROFILES;
} else {
process.env.NIX_PROFILES = saved;
}
_resetResolveSystemBin();
}
});
}
if (process.platform === "darwin") {
it("does not include /opt/homebrew/bin in strict trust on macOS", () => {
expect(_getTrustedDirs("strict")).not.toContain("/opt/homebrew/bin");
expect(_getTrustedDirs("strict")).not.toContain("/usr/local/bin");
});
it("includes /opt/homebrew/bin and /usr/local/bin in standard trust on macOS", () => {
const dirs = _getTrustedDirs("standard");
expect(dirs).toContain("/opt/homebrew/bin");
expect(dirs).toContain("/usr/local/bin");
});
it("places Homebrew dirs after system dirs in standard trust", () => {
const dirs = [..._getTrustedDirs("standard")];
const usrBinIdx = dirs.indexOf("/usr/bin");
const brewIdx = dirs.indexOf("/opt/homebrew/bin");
const localIdx = dirs.indexOf("/usr/local/bin");
expect(usrBinIdx).toBeGreaterThanOrEqual(0);
expect(brewIdx).toBeGreaterThan(usrBinIdx);
expect(localIdx).toBeGreaterThan(usrBinIdx);
});
it("standard trust is a superset of strict trust on macOS", () => {
const strict = _getTrustedDirs("strict");
const standard = _getTrustedDirs("standard");
for (const dir of strict) {
expect(standard, `standard trust should include strict dir ${dir}`).toContain(dir);
}
});
}
if (process.platform === "linux") {
it("includes Linux system-managed directories", () => {
const dirs = _getTrustedDirs();
expect(dirs).toContain("/run/current-system/sw/bin");
expect(dirs).toContain("/snap/bin");
});
it("includes /usr/local/bin in standard trust on Linux", () => {
const dirs = _getTrustedDirs("standard");
expect(dirs).toContain("/usr/local/bin");
});
it("places /usr/local/bin after /usr/bin in standard trust on Linux", () => {
const dirs = [..._getTrustedDirs("standard")];
const usrBinIdx = dirs.indexOf("/usr/bin");
const usrLocalBinIdx = dirs.indexOf("/usr/local/bin");
expect(usrBinIdx).toBeGreaterThanOrEqual(0);
expect(usrLocalBinIdx).toBeGreaterThan(usrBinIdx);
});
}
if (process.platform !== "darwin" && process.platform !== "linux") {
it("standard trust equals strict trust on platforms without expansion", () => {
const strict = _getTrustedDirs("strict");
const standard = _getTrustedDirs("standard");
expect(standard).toEqual(strict);
});
}
if (process.platform === "win32") {
it("includes Windows system directories", () => {
const dirs = _getTrustedDirs();
expect(dirs).toContain(path.win32.join(getWindowsInstallRoots().systemRoot, "System32"));
});
it("includes Program Files OpenSSL and ffmpeg paths", () => {
const dirs = _getTrustedDirs();
for (const programFilesRoot of getWindowsProgramFilesRoots()) {
expect(dirs).toContain(path.win32.join(programFilesRoot, "OpenSSL-Win64", "bin"));
expect(dirs).toContain(path.win32.join(programFilesRoot, "ffmpeg", "bin"));
}
});
it("uses validated Windows install roots from HKLM values", () => {
_resetWindowsInstallRootsForTests({
queryRegistryValue: (key, valueName) => {
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" &&
valueName === "SystemRoot"
) {
return "D:\\Windows";
}
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" &&
valueName === "ProgramFilesDir"
) {
return "D:\\Program Files";
}
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" &&
valueName === "ProgramW6432Dir"
) {
return "D:\\Program Files";
}
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" &&
valueName === "ProgramFilesDir (x86)"
) {
return "E:\\Program Files (x86)";
}
return null;
},
});
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
const dirs = _getTrustedDirs();
expect(dirs).toContain(path.win32.join("D:\\Windows", "System32"));
expect(dirs).toContain(path.win32.join("D:\\Program Files", "OpenSSL-Win64", "bin"));
expect(dirs).toContain(path.win32.join("E:\\Program Files (x86)", "OpenSSL", "bin"));
});
it("falls back safely when HKLM values are unavailable", () => {
_resetWindowsInstallRootsForTests({
queryRegistryValue: () => null,
});
_resetResolveSystemBin((p: string) => executables.has(path.resolve(p)));
const dirs = _getTrustedDirs();
const normalizedDirs = dirs.map((dir) => dir.toLowerCase());
expect(normalizedDirs).toContain(path.win32.join("C:\\Windows", "System32").toLowerCase());
expect(normalizedDirs).toContain(
path.win32.join("C:\\Program Files", "OpenSSL-Win64", "bin").toLowerCase(),
);
expect(normalizedDirs).toContain(
path.win32.join("C:\\Program Files (x86)", "OpenSSL", "bin").toLowerCase(),
);
});
it("does not include Unix paths on Windows", () => {
const dirs = _getTrustedDirs();
expect(dirs).not.toContain("/usr/bin");
expect(dirs).not.toContain("/bin");
});
}
});

View File

@@ -0,0 +1,187 @@
import fs from "node:fs";
import path from "node:path";
import { getWindowsInstallRoots, getWindowsProgramFilesRoots } from "./windows-install-roots.js";
/**
* Trust level for system binary resolution.
* - "strict": Only fixed OS-managed directories. Use for security-critical
* binaries like openssl where a compromised binary has high impact.
* - "standard": Strict dirs plus common local-admin/package-manager
* directories appended after system dirs. Use for tool binaries like
* ffmpeg that are rarely available via the OS itself.
*/
export type SystemBinTrust = "strict" | "standard";
// Unix directories where OS-managed or system-installed binaries live.
// User-writable or package-manager-managed directories are excluded so that
// attacker-planted binaries cannot shadow legitimate system executables.
const UNIX_BASE_TRUSTED_DIRS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] as const;
// Package-manager directories appended in "standard" trust on macOS.
// These come after strict dirs so OS binaries always take priority.
// Could be acceptable for tooling binaries like ffmpeg but NOT for
// security-critical ones like openssl — callers needing higher
// assurance should stick with "strict".
const DARWIN_STANDARD_DIRS = ["/opt/homebrew/bin", "/usr/local/bin"] as const;
const LINUX_STANDARD_DIRS = ["/usr/local/bin"] as const;
// Windows extensions to probe when searching for executables.
const WIN_PATHEXT = [".exe", ".cmd", ".bat", ".com"] as const;
const resolvedCacheStrict = new Map<string, string>();
const resolvedCacheStandard = new Map<string, string>();
function defaultIsExecutable(filePath: string): boolean {
try {
if (process.platform === "win32") {
fs.accessSync(filePath, fs.constants.R_OK);
} else {
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
} catch {
return false;
}
}
let isExecutableFn: (filePath: string) => boolean = defaultIsExecutable;
/**
* Build the trusted-dir list for Windows. Only system-managed directories
* are included; user-profile paths like %LOCALAPPDATA% are excluded.
*/
function buildWindowsTrustedDirs(): readonly string[] {
const dirs: string[] = [];
const { systemRoot } = getWindowsInstallRoots();
dirs.push(path.win32.join(systemRoot, "System32"));
dirs.push(path.win32.join(systemRoot, "SysWOW64"));
for (const programFilesRoot of getWindowsProgramFilesRoots()) {
// Trust the machine's validated Program Files roots rather than assuming C:.
dirs.push(path.win32.join(programFilesRoot, "OpenSSL-Win64", "bin"));
dirs.push(path.win32.join(programFilesRoot, "OpenSSL", "bin"));
dirs.push(path.win32.join(programFilesRoot, "ffmpeg", "bin"));
}
return dirs;
}
/**
* Build the trusted-dir list for Unix (macOS, Linux, etc.), extending
* UNIX_BASE_TRUSTED_DIRS with platform/environment-specific paths.
*
* Strict: only fixed OS-managed directories.
*
* Standard: strict dirs plus platform package-manager directories appended
* after, so OS binaries always take priority.
*/
function buildUnixTrustedDirs(trust: SystemBinTrust): readonly string[] {
const dirs: string[] = [...UNIX_BASE_TRUSTED_DIRS];
const platform = process.platform;
if (platform === "linux") {
// Fixed NixOS system profile path. Never derive trust from NIX_PROFILES:
// env-controlled Nix store/profile entries can be attacker-selected.
// Callers that intentionally rely on non-default Nix paths must opt in via extraDirs.
dirs.push("/run/current-system/sw/bin");
dirs.push("/snap/bin");
}
// "standard" trust widens the search for non-security-critical tools in
// common local-admin/package-manager directories, while keeping strict dirs
// first so OS binaries always take priority.
if (trust === "standard") {
if (platform === "darwin") {
dirs.push(...DARWIN_STANDARD_DIRS);
} else if (platform === "linux") {
dirs.push(...LINUX_STANDARD_DIRS);
}
}
return dirs;
}
let trustedDirsStrict: readonly string[] | null = null;
let trustedDirsStandard: readonly string[] | null = null;
function getTrustedDirs(trust: SystemBinTrust): readonly string[] {
if (process.platform === "win32") {
// Windows does not currently widen "standard" beyond the registry-backed
// system roots; both trust levels intentionally share the same set today.
trustedDirsStrict ??= buildWindowsTrustedDirs();
return trustedDirsStrict;
}
if (trust === "standard") {
trustedDirsStandard ??= buildUnixTrustedDirs("standard");
return trustedDirsStandard;
}
trustedDirsStrict ??= buildUnixTrustedDirs("strict");
return trustedDirsStrict;
}
/**
* Resolve a binary name to an absolute path by searching only trusted system
* directories. Returns `null` when the binary is not found. Results are cached
* for the lifetime of the process.
*
* This MUST be used instead of bare binary names in `execFile`/`spawn` calls
* for internal infrastructure binaries (ffmpeg, ffprobe, openssl, etc.) to
* prevent PATH-hijack attacks via user-writable directories.
*/
export function resolveSystemBin(
name: string,
opts?: { trust?: SystemBinTrust; extraDirs?: readonly string[] },
): string | null {
const trust = opts?.trust ?? "strict";
const hasExtra = (opts?.extraDirs?.length ?? 0) > 0;
const cache = trust === "standard" ? resolvedCacheStandard : resolvedCacheStrict;
if (!hasExtra) {
const cached = cache.get(name);
if (cached !== undefined) {
return cached;
}
}
const dirs = [...getTrustedDirs(trust), ...(opts?.extraDirs ?? [])];
const isWin = process.platform === "win32";
const hasExt = isWin && path.win32.extname(name).length > 0;
for (const dir of dirs) {
if (isWin && !hasExt) {
for (const ext of WIN_PATHEXT) {
const candidate = path.win32.join(dir, name + ext);
if (isExecutableFn(candidate)) {
if (!hasExtra) {
cache.set(name, candidate);
}
return candidate;
}
}
} else {
const candidate = path.join(dir, name);
if (isExecutableFn(candidate)) {
if (!hasExtra) {
cache.set(name, candidate);
}
return candidate;
}
}
}
return null;
}
/** Visible for tests: the computed trusted directories. */
export function _getTrustedDirs(trust: SystemBinTrust = "strict"): readonly string[] {
return getTrustedDirs(trust);
}
/** Reset cache and optionally override the executable-check function (for tests). */
export function _resetResolveSystemBin(overrideIsExecutable?: (p: string) => boolean): void {
resolvedCacheStrict.clear();
resolvedCacheStandard.clear();
trustedDirsStrict = null;
trustedDirsStandard = null;
isExecutableFn = overrideIsExecutable ?? defaultIsExecutable;
}

View File

@@ -6,6 +6,7 @@ import tls from "node:tls";
import { promisify } from "node:util";
import type { GatewayTlsConfig } from "../../config/types.gateway.js";
import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js";
import { resolveSystemBin } from "../resolve-system-bin.js";
import { normalizeFingerprint } from "./fingerprint.js";
const execFileAsync = promisify(execFile);
@@ -41,7 +42,13 @@ async function generateSelfSignedCert(params: {
if (keyDir !== certDir) {
await ensureDir(keyDir);
}
await execFileAsync("openssl", [
const opensslBin = resolveSystemBin("openssl");
if (!opensslBin) {
throw new Error(
"openssl not found in trusted system directories. Install it in an OS-managed location.",
);
}
await execFileAsync(opensslBin, [
"req",
"-x509",
"-newkey",

View File

@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it } from "vitest";
import {
_private,
_resetWindowsInstallRootsForTests,
getWindowsInstallRoots,
getWindowsProgramFilesRoots,
normalizeWindowsInstallRoot,
} from "./windows-install-roots.js";
afterEach(() => {
_resetWindowsInstallRootsForTests();
});
describe("normalizeWindowsInstallRoot", () => {
it("normalizes validated local Windows roots", () => {
expect(normalizeWindowsInstallRoot(" D:/Apps/Program Files/ ")).toBe("D:\\Apps\\Program Files");
});
it("rejects invalid or overly broad values", () => {
expect(normalizeWindowsInstallRoot("relative\\path")).toBeNull();
expect(normalizeWindowsInstallRoot("\\\\server\\share\\Program Files")).toBeNull();
expect(normalizeWindowsInstallRoot("D:\\")).toBeNull();
expect(normalizeWindowsInstallRoot("D:\\Apps;E:\\Other")).toBeNull();
});
});
describe("getWindowsInstallRoots", () => {
it("prefers HKLM registry roots over process environment values", () => {
_resetWindowsInstallRootsForTests({
queryRegistryValue: (key, valueName) => {
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" &&
valueName === "SystemRoot"
) {
return "D:\\Windows";
}
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" &&
valueName === "ProgramFilesDir"
) {
return "E:\\Programs";
}
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" &&
valueName === "ProgramFilesDir (x86)"
) {
return "F:\\Programs (x86)";
}
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" &&
valueName === "ProgramW6432Dir"
) {
return "E:\\Programs";
}
return null;
},
});
const originalEnv = process.env;
let roots;
try {
process.env = {
...originalEnv,
SystemRoot: "C:\\PoisonedWindows",
ProgramFiles: "C:\\Poisoned Programs",
"ProgramFiles(x86)": "C:\\Poisoned Programs (x86)",
ProgramW6432: "C:\\Poisoned Programs",
};
roots = getWindowsInstallRoots();
} finally {
process.env = originalEnv;
}
expect(roots).toEqual({
systemRoot: "D:\\Windows",
programFiles: "E:\\Programs",
programFilesX86: "F:\\Programs (x86)",
programW6432: "E:\\Programs",
});
});
it("uses explicit env roots without consulting HKLM", () => {
_resetWindowsInstallRootsForTests({
queryRegistryValue: (key, valueName) => {
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" &&
valueName === "SystemRoot"
) {
return "D:\\Windows";
}
if (
key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" &&
valueName === "ProgramFilesDir"
) {
return "E:\\Programs";
}
return null;
},
});
const roots = getWindowsInstallRoots({
SystemRoot: "G:\\Windows",
ProgramFiles: "H:\\Programs",
"ProgramFiles(x86)": "I:\\Programs (x86)",
ProgramW6432: "H:\\Programs",
});
expect(roots).toEqual({
systemRoot: "G:\\Windows",
programFiles: "H:\\Programs",
programFilesX86: "I:\\Programs (x86)",
programW6432: "H:\\Programs",
});
});
it("falls back to validated env roots when registry lookup is unavailable", () => {
_resetWindowsInstallRootsForTests({
queryRegistryValue: () => null,
});
const roots = getWindowsInstallRoots({
systemroot: "D:\\Windows\\",
programfiles: "E:\\Programs",
"PROGRAMFILES(X86)": "F:\\Programs (x86)\\",
programw6432: "E:\\Programs",
});
expect(roots).toEqual({
systemRoot: "D:\\Windows",
programFiles: "E:\\Programs",
programFilesX86: "F:\\Programs (x86)",
programW6432: "E:\\Programs",
});
});
it("falls back to defaults when registry and env roots are invalid", () => {
_resetWindowsInstallRootsForTests({
queryRegistryValue: () => "relative\\path",
});
const roots = getWindowsInstallRoots({
SystemRoot: "relative\\Windows",
ProgramFiles: "\\\\server\\share\\Program Files",
"ProgramFiles(x86)": "D:\\",
ProgramW6432: "C:\\Programs;D:\\Other",
});
expect(roots).toEqual({
systemRoot: "C:\\Windows",
programFiles: "C:\\Program Files",
programFilesX86: "C:\\Program Files (x86)",
programW6432: null,
});
});
});
describe("getWindowsProgramFilesRoots", () => {
it("prefers ProgramW6432 and dedupes roots case-insensitively", () => {
_resetWindowsInstallRootsForTests({
queryRegistryValue: () => null,
});
expect(
getWindowsProgramFilesRoots({
ProgramW6432: "D:\\Programs",
ProgramFiles: "d:\\Programs\\",
"ProgramFiles(x86)": "E:\\Programs (x86)",
}),
).toEqual(["D:\\Programs", "E:\\Programs (x86)"]);
});
});
describe("locateWindowsRegExe", () => {
it("prefers SystemRoot and WINDIR candidates over arbitrary drive scans", () => {
expect(
_private.getWindowsRegExeCandidates({
SystemRoot: "D:\\Windows",
WINDIR: "E:\\Windows",
}),
).toEqual([
"D:\\Windows\\System32\\reg.exe",
"E:\\Windows\\System32\\reg.exe",
"C:\\Windows\\System32\\reg.exe",
]);
});
it("dedupes equivalent roots case-insensitively", () => {
expect(
_private.getWindowsRegExeCandidates({
SystemRoot: "D:\\Windows\\",
windir: "d:\\windows",
}),
).toEqual(["D:\\Windows\\System32\\reg.exe", "C:\\Windows\\System32\\reg.exe"]);
});
});

View File

@@ -0,0 +1,263 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const DEFAULT_SYSTEM_ROOT = "C:\\Windows";
const DEFAULT_PROGRAM_FILES = "C:\\Program Files";
const DEFAULT_PROGRAM_FILES_X86 = "C:\\Program Files (x86)";
const WINDOWS_NT_CURRENT_VERSION_KEY = "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion";
const WINDOWS_CURRENT_VERSION_KEY = "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion";
const REG_QUERY_TIMEOUT_MS = 5_000;
type QueryRegistryValue = (key: string, valueName: string) => string | null;
type IsReadableFile = (filePath: string) => boolean;
type WindowsInstallRootsTestOverrides = {
queryRegistryValue?: QueryRegistryValue;
isReadableFile?: IsReadableFile;
};
export type WindowsInstallRoots = {
systemRoot: string;
programFiles: string;
programFilesX86: string;
programW6432: string | null;
};
let queryRegistryValueFn: QueryRegistryValue = defaultQueryRegistryValue;
let isReadableFileFn: IsReadableFile = defaultIsReadableFile;
let cachedProcessInstallRoots: WindowsInstallRoots | null = null;
function defaultIsReadableFile(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
function trimTrailingSeparators(value: string): string {
const parsed = path.win32.parse(value);
let trimmed = value;
while (trimmed.length > parsed.root.length && /[\\/]/.test(trimmed.at(-1) ?? "")) {
trimmed = trimmed.slice(0, -1);
}
return trimmed;
}
/**
* Windows install roots should be local absolute directories, not drive-relative
* paths, UNC shares, or PATH-like lists that could widen trust unexpectedly.
*/
export function normalizeWindowsInstallRoot(raw: string | undefined): string | null {
if (typeof raw !== "string") {
return null;
}
const trimmed = raw.trim();
if (
!trimmed ||
trimmed.includes("\0") ||
trimmed.includes("\r") ||
trimmed.includes("\n") ||
trimmed.includes(";")
) {
return null;
}
const normalized = trimTrailingSeparators(path.win32.normalize(trimmed));
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
return null;
}
const parsed = path.win32.parse(normalized);
if (!/^[A-Za-z]:\\$/.test(parsed.root)) {
return null;
}
if (normalized.length <= parsed.root.length) {
return null;
}
return normalized;
}
function getEnvValueCaseInsensitive(
env: Record<string, string | undefined>,
expectedKey: string,
): string | undefined {
const direct = env[expectedKey];
if (direct !== undefined) {
return direct;
}
const upper = expectedKey.toUpperCase();
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === upper);
return actualKey ? env[actualKey] : undefined;
}
function getWindowsRegExeCandidates(env: Record<string, string | undefined>): readonly string[] {
const seen = new Set<string>();
const candidates: string[] = [];
for (const root of [
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "SystemRoot")),
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "WINDIR")),
DEFAULT_SYSTEM_ROOT,
]) {
if (!root) {
continue;
}
const key = root.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
candidates.push(path.win32.join(root, "System32", "reg.exe"));
}
return candidates;
}
function locateWindowsRegExe(env: Record<string, string | undefined> = process.env): string | null {
for (const candidate of getWindowsRegExeCandidates(env)) {
if (isReadableFileFn(candidate)) {
return candidate;
}
}
return null;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function parseRegQueryValue(stdout: string, valueName: string): string | null {
const pattern = new RegExp(`^\\s*${escapeRegex(valueName)}\\s+REG_[A-Z0-9_]+\\s+(.+)$`, "im");
const match = stdout.match(pattern);
return match?.[1]?.trim() || null;
}
function runRegQuery(
regExe: string,
key: string,
valueName: string,
use64BitView: boolean,
): string {
const args = ["query", key, "/v", valueName];
if (use64BitView) {
args.push("/reg:64");
}
return execFileSync(regExe, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: REG_QUERY_TIMEOUT_MS,
windowsHide: true,
});
}
function defaultQueryRegistryValue(key: string, valueName: string): string | null {
const regExe = locateWindowsRegExe(process.env);
if (!regExe) {
return null;
}
for (const use64BitView of [true, false]) {
try {
const stdout = runRegQuery(regExe, key, valueName, use64BitView);
const parsed = parseRegQueryValue(stdout, valueName);
if (parsed) {
return parsed;
}
} catch {
// Keep trying alternate registry views or fallbacks below.
}
}
return null;
}
function getRegistryInstallRoots(): Partial<WindowsInstallRoots> {
return {
systemRoot:
normalizeWindowsInstallRoot(
queryRegistryValueFn(WINDOWS_NT_CURRENT_VERSION_KEY, "SystemRoot") ?? undefined,
) ?? undefined,
programFiles:
normalizeWindowsInstallRoot(
queryRegistryValueFn(WINDOWS_CURRENT_VERSION_KEY, "ProgramFilesDir") ?? undefined,
) ?? undefined,
programFilesX86:
normalizeWindowsInstallRoot(
queryRegistryValueFn(WINDOWS_CURRENT_VERSION_KEY, "ProgramFilesDir (x86)") ?? undefined,
) ?? undefined,
programW6432:
normalizeWindowsInstallRoot(
queryRegistryValueFn(WINDOWS_CURRENT_VERSION_KEY, "ProgramW6432Dir") ?? undefined,
) ?? undefined,
};
}
function buildWindowsInstallRoots(
env: Record<string, string | undefined>,
useRegistryRoots: boolean,
): WindowsInstallRoots {
const registryRoots = useRegistryRoots ? getRegistryInstallRoots() : {};
const envProgramW6432 = normalizeWindowsInstallRoot(
getEnvValueCaseInsensitive(env, "ProgramW6432"),
);
const programW6432 = registryRoots.programW6432 ?? envProgramW6432 ?? null;
return {
systemRoot:
registryRoots.systemRoot ??
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
DEFAULT_SYSTEM_ROOT,
programFiles:
registryRoots.programFiles ??
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "ProgramFiles")) ??
programW6432 ??
DEFAULT_PROGRAM_FILES,
programFilesX86:
registryRoots.programFilesX86 ??
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "ProgramFiles(x86)")) ??
DEFAULT_PROGRAM_FILES_X86,
programW6432,
};
}
export function getWindowsInstallRoots(
env: Record<string, string | undefined> = process.env,
): WindowsInstallRoots {
if (env === process.env) {
cachedProcessInstallRoots ??= buildWindowsInstallRoots(env, true);
return cachedProcessInstallRoots;
}
return buildWindowsInstallRoots(env, false);
}
export function getWindowsProgramFilesRoots(
env: Record<string, string | undefined> = process.env,
): readonly string[] {
const roots = getWindowsInstallRoots(env);
const seen = new Set<string>();
const result: string[] = [];
for (const value of [roots.programW6432, roots.programFiles, roots.programFilesX86]) {
if (!value) {
continue;
}
const key = value.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(value);
}
return result;
}
export function _resetWindowsInstallRootsForTests(
overrides: WindowsInstallRootsTestOverrides = {},
): void {
queryRegistryValueFn = overrides.queryRegistryValue ?? defaultQueryRegistryValue;
isReadableFileFn = overrides.isReadableFile ?? defaultIsReadableFile;
cachedProcessInstallRoots = null;
}
export const _private = {
getWindowsRegExeCandidates,
locateWindowsRegExe,
};

View File

@@ -1,5 +1,6 @@
import { execFile, type ExecFileOptions } from "node:child_process";
import { promisify } from "node:util";
import { resolveSystemBin } from "../infra/resolve-system-bin.js";
import {
MEDIA_FFMPEG_MAX_BUFFER_BYTES,
MEDIA_FFMPEG_TIMEOUT_MS,
@@ -23,9 +24,24 @@ function resolveExecOptions(
};
}
function requireSystemBin(name: string): string {
const resolved = resolveSystemBin(name, { trust: "standard" });
if (!resolved) {
const hint =
process.platform === "darwin"
? "e.g. brew install ffmpeg"
: "e.g. apt install ffmpeg / dnf install ffmpeg";
throw new Error(
`${name} not found in trusted system directories. ` +
`Install it via your system package manager (${hint}).`,
);
}
return resolved;
}
export async function runFfprobe(args: string[], options?: MediaExecOptions): Promise<string> {
const { stdout } = await execFileAsync(
"ffprobe",
requireSystemBin("ffprobe"),
args,
resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options),
);
@@ -34,7 +50,7 @@ export async function runFfprobe(args: string[], options?: MediaExecOptions): Pr
export async function runFfmpeg(args: string[], options?: MediaExecOptions): Promise<string> {
const { stdout } = await execFileAsync(
"ffmpeg",
requireSystemBin("ffmpeg"),
args,
resolveExecOptions(MEDIA_FFMPEG_TIMEOUT_MS, options),
);