import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { isDangerousHostEnvOverrideVarName, isDangerousHostEnvVarName, normalizeEnvVarKey, sanitizeHostExecEnv, sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js"; function getSystemGitPath() { if (process.platform === "win32") { return null; } const gitPath = "/usr/bin/git"; return fs.existsSync(gitPath) ? gitPath : null; } function clearMarker(marker: string) { try { fs.unlinkSync(marker); } catch { // no-op } } async function runGitLsRemote(gitPath: string, target: string, env: NodeJS.ProcessEnv) { await new Promise((resolve) => { const child = spawn(gitPath, ["ls-remote", target], { env, stdio: "ignore" }); child.once("error", () => resolve()); child.once("close", () => resolve()); }); } describe("isDangerousHostEnvVarName", () => { it("matches dangerous keys and prefixes case-insensitively", () => { expect(isDangerousHostEnvVarName("BASH_ENV")).toBe(true); expect(isDangerousHostEnvVarName("bash_env")).toBe(true); expect(isDangerousHostEnvVarName("SHELL")).toBe(true); expect(isDangerousHostEnvVarName("GIT_EXTERNAL_DIFF")).toBe(true); expect(isDangerousHostEnvVarName("git_exec_path")).toBe(true); expect(isDangerousHostEnvVarName("SHELLOPTS")).toBe(true); expect(isDangerousHostEnvVarName("ps4")).toBe(true); expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true); expect(isDangerousHostEnvVarName("ld_preload")).toBe(true); expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true); expect(isDangerousHostEnvVarName("PATH")).toBe(false); expect(isDangerousHostEnvVarName("FOO")).toBe(false); }); }); describe("sanitizeHostExecEnv", () => { it("removes dangerous inherited keys while preserving PATH", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", BASH_ENV: "/tmp/pwn.sh", GIT_EXTERNAL_DIFF: "/tmp/pwn.sh", LD_PRELOAD: "/tmp/pwn.so", OK: "1", }, }); expect(env).toEqual({ OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, PATH: "/usr/bin:/bin", OK: "1", }); }); it("blocks PATH and dangerous override values", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", HOME: "/tmp/trusted-home", ZDOTDIR: "/tmp/trusted-zdotdir", }, overrides: { PATH: "/tmp/evil", HOME: "/tmp/evil-home", ZDOTDIR: "/tmp/evil-zdotdir", BASH_ENV: "/tmp/pwn.sh", GIT_SSH_COMMAND: "touch /tmp/pwned", GIT_EXEC_PATH: "/tmp/git-exec-path", EDITOR: "/tmp/editor", NPM_CONFIG_USERCONFIG: "/tmp/npmrc", GIT_CONFIG_GLOBAL: "/tmp/gitconfig", SHELLOPTS: "xtrace", PS4: "$(touch /tmp/pwned)", SAFE: "ok", }, }); expect(env.PATH).toBe("/usr/bin:/bin"); expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.BASH_ENV).toBeUndefined(); expect(env.GIT_SSH_COMMAND).toBeUndefined(); expect(env.GIT_EXEC_PATH).toBeUndefined(); expect(env.EDITOR).toBeUndefined(); expect(env.NPM_CONFIG_USERCONFIG).toBeUndefined(); expect(env.GIT_CONFIG_GLOBAL).toBeUndefined(); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); expect(env.SAFE).toBe("ok"); expect(env.HOME).toBe("/tmp/trusted-home"); expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir"); }); it("drops dangerous inherited shell trace keys", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", SHELLOPTS: "xtrace", PS4: "$(touch /tmp/pwned)", OK: "1", }, }); expect(env.PATH).toBe("/usr/bin:/bin"); expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.OK).toBe("1"); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); }); it("drops non-portable env key names", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", }, overrides: { " BAD KEY": "x", "NOT-PORTABLE": "x", GOOD_KEY: "ok", }, }); expect(env.GOOD_KEY).toBe("ok"); expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env[" BAD KEY"]).toBeUndefined(); expect(env["NOT-PORTABLE"]).toBeUndefined(); }); it("can allow PATH overrides when explicitly opted out of blocking", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", }, overrides: { PATH: "/custom/bin", }, blockPathOverrides: false, }); expect(env.PATH).toBe("/custom/bin"); expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); }); it("drops non-string inherited values and non-portable inherited keys", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", GOOD: "1", // oxlint-disable-next-line typescript/no-explicit-any BAD_NUMBER: 1 as any, "NOT-PORTABLE": "x", }, }); expect(env).toEqual({ OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, PATH: "/usr/bin:/bin", GOOD: "1", }); }); }); describe("isDangerousHostEnvOverrideVarName", () => { it("matches override-only blocked keys case-insensitively", () => { expect(isDangerousHostEnvOverrideVarName("HOME")).toBe(true); expect(isDangerousHostEnvOverrideVarName("zdotdir")).toBe(true); expect(isDangerousHostEnvOverrideVarName("GIT_SSH_COMMAND")).toBe(true); expect(isDangerousHostEnvOverrideVarName("editor")).toBe(true); expect(isDangerousHostEnvOverrideVarName("NPM_CONFIG_USERCONFIG")).toBe(true); expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true); expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false); expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false); }); }); describe("normalizeEnvVarKey", () => { it("normalizes and validates keys", () => { expect(normalizeEnvVarKey(" OPENROUTER_API_KEY ")).toBe("OPENROUTER_API_KEY"); expect(normalizeEnvVarKey("NOT-PORTABLE", { portable: true })).toBeNull(); expect(normalizeEnvVarKey(" BASH_FUNC_echo%% ")).toBe("BASH_FUNC_echo%%"); expect(normalizeEnvVarKey(" ")).toBeNull(); }); }); describe("sanitizeSystemRunEnvOverrides", () => { it("keeps overrides for non-shell commands", () => { const overrides = sanitizeSystemRunEnvOverrides({ shellWrapper: false, overrides: { OPENCLAW_TEST: "1", TOKEN: "abc", }, }); expect(overrides).toEqual({ OPENCLAW_TEST: "1", TOKEN: "abc", }); }); it("drops non-allowlisted overrides for shell wrappers", () => { const overrides = sanitizeSystemRunEnvOverrides({ shellWrapper: true, overrides: { OPENCLAW_TEST: "1", TOKEN: "abc", LANG: "C", LC_ALL: "C", }, }); expect(overrides).toEqual({ LANG: "C", LC_ALL: "C", }); }); it("returns undefined when no shell-wrapper overrides survive", () => { expect( sanitizeSystemRunEnvOverrides({ shellWrapper: true, overrides: { TOKEN: "abc", }, }), ).toBeUndefined(); expect(sanitizeSystemRunEnvOverrides({ shellWrapper: true })).toBeUndefined(); }); it("keeps allowlisted shell-wrapper overrides case-insensitively", () => { expect( sanitizeSystemRunEnvOverrides({ shellWrapper: true, overrides: { lang: "C", ColorTerm: "truecolor", }, }), ).toEqual({ lang: "C", ColorTerm: "truecolor", }); }); }); describe("shell wrapper exploit regression", () => { it("blocks SHELLOPTS/PS4 chain after sanitization", async () => { const bashPath = "/bin/bash"; if (process.platform === "win32" || !fs.existsSync(bashPath)) { return; } const marker = path.join(os.tmpdir(), `openclaw-ps4-marker-${process.pid}-${Date.now()}`); try { fs.unlinkSync(marker); } catch { // no-op } const filteredOverrides = sanitizeSystemRunEnvOverrides({ shellWrapper: true, overrides: { SHELLOPTS: "xtrace", PS4: `$(touch ${marker})`, }, }); const env = sanitizeHostExecEnv({ overrides: filteredOverrides, baseEnv: { PATH: process.env.PATH ?? "/usr/bin:/bin", }, }); await new Promise((resolve, reject) => { const child = spawn(bashPath, ["-lc", "echo SAFE"], { env, stdio: "ignore" }); child.once("error", reject); child.once("close", () => resolve()); }); expect(fs.existsSync(marker)).toBe(false); }); }); describe("git env exploit regression", () => { it("blocks inherited GIT_EXEC_PATH so git cannot execute helper payloads", async () => { const gitPath = getSystemGitPath(); if (!gitPath) { return; } const helperDir = fs.mkdtempSync( path.join(os.tmpdir(), `openclaw-git-exec-path-${process.pid}-${Date.now()}-`), ); const helperPath = path.join(helperDir, "git-remote-https"); const marker = path.join( os.tmpdir(), `openclaw-git-exec-path-marker-${process.pid}-${Date.now()}`, ); try { clearMarker(marker); fs.writeFileSync(helperPath, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nexit 1\n`, "utf8"); fs.chmodSync(helperPath, 0o755); const target = "https://127.0.0.1:1/does-not-matter"; const unsafeEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin", GIT_EXEC_PATH: helperDir, GIT_TERMINAL_PROMPT: "0", }; await runGitLsRemote(gitPath, target, unsafeEnv); expect(fs.existsSync(marker)).toBe(true); clearMarker(marker); const safeEnv = sanitizeHostExecEnv({ baseEnv: unsafeEnv, }); await runGitLsRemote(gitPath, target, safeEnv); expect(fs.existsSync(marker)).toBe(false); } finally { fs.rmSync(helperDir, { recursive: true, force: true }); fs.rmSync(marker, { force: true }); } }); it("blocks GIT_SSH_COMMAND override so git cannot execute helper payloads", async () => { const gitPath = getSystemGitPath(); if (!gitPath) { return; } const marker = path.join(os.tmpdir(), `openclaw-git-ssh-command-${process.pid}-${Date.now()}`); clearMarker(marker); const target = "ssh://127.0.0.1:1/does-not-matter"; const exploitValue = `touch ${JSON.stringify(marker)}; false`; const baseEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin", GIT_TERMINAL_PROMPT: "0", }; const unsafeEnv = { ...baseEnv, GIT_SSH_COMMAND: exploitValue, }; await runGitLsRemote(gitPath, target, unsafeEnv); expect(fs.existsSync(marker)).toBe(true); clearMarker(marker); const safeEnv = sanitizeHostExecEnv({ baseEnv, overrides: { GIT_SSH_COMMAND: exploitValue, }, }); await runGitLsRemote(gitPath, target, safeEnv); expect(fs.existsSync(marker)).toBe(false); }); });