mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 10:22:32 +00:00
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:
@@ -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> = [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
303
src/infra/resolve-system-bin.test.ts
Normal file
303
src/infra/resolve-system-bin.test.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
});
|
||||
187
src/infra/resolve-system-bin.ts
Normal file
187
src/infra/resolve-system-bin.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
195
src/infra/windows-install-roots.test.ts
Normal file
195
src/infra/windows-install-roots.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
263
src/infra/windows-install-roots.ts
Normal file
263
src/infra/windows-install-roots.ts
Normal 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,
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user