fix(exec): block dangerous override-only env pivots

This commit is contained in:
Peter Steinberger
2026-03-07 19:17:59 +00:00
parent 6aa80844b8
commit e27bbe4982
8 changed files with 155 additions and 5 deletions

View File

@@ -18,6 +18,33 @@
"IFS",
"SSLKEYLOGFILE"
],
"blockedOverrideKeys": ["HOME", "ZDOTDIR"],
"blockedOverrideKeys": [
"HOME",
"ZDOTDIR",
"GIT_SSH_COMMAND",
"GIT_SSH",
"GIT_PROXY_COMMAND",
"GIT_ASKPASS",
"SSH_ASKPASS",
"LESSOPEN",
"LESSCLOSE",
"PAGER",
"MANPAGER",
"GIT_PAGER",
"EDITOR",
"VISUAL",
"FCEDIT",
"SUDO_EDITOR",
"PROMPT_COMMAND",
"HISTFILE",
"PERL5DB",
"PERL5DBCMD",
"OPENSSL_CONF",
"OPENSSL_ENGINES",
"PYTHONSTARTUP",
"WGETRC",
"CURL_HOME"
],
"blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_"],
"blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"]
}

View File

@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
type HostEnvSecurityPolicy = {
blockedKeys: string[];
blockedOverrideKeys?: string[];
blockedOverridePrefixes?: string[];
blockedPrefixes: string[];
};
@@ -40,6 +41,10 @@ describe("host env security policy parity", () => {
generatedSource,
"static let blockedOverrideKeys",
);
const swiftBlockedOverridePrefixes = parseSwiftStringArray(
generatedSource,
"static let blockedOverridePrefixes",
);
const swiftBlockedPrefixes = parseSwiftStringArray(
generatedSource,
"static let blockedPrefixes",
@@ -47,6 +52,7 @@ describe("host env security policy parity", () => {
expect(swiftBlockedKeys).toEqual(policy.blockedKeys);
expect(swiftBlockedOverrideKeys).toEqual(policy.blockedOverrideKeys ?? []);
expect(swiftBlockedOverridePrefixes).toEqual(policy.blockedOverridePrefixes ?? []);
expect(swiftBlockedPrefixes).toEqual(policy.blockedPrefixes);
expect(sanitizerSource).toContain(
@@ -55,6 +61,9 @@ describe("host env security policy parity", () => {
expect(sanitizerSource).toContain(
"private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys",
);
expect(sanitizerSource).toContain(
"private static let blockedOverridePrefixes = HostEnvSecurityPolicy.blockedOverridePrefixes",
);
expect(sanitizerSource).toContain(
"private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes",
);

View File

@@ -57,6 +57,10 @@ describe("sanitizeHostExecEnv", () => {
HOME: "/tmp/evil-home",
ZDOTDIR: "/tmp/evil-zdotdir",
BASH_ENV: "/tmp/pwn.sh",
GIT_SSH_COMMAND: "touch /tmp/pwned",
EDITOR: "/tmp/editor",
NPM_CONFIG_USERCONFIG: "/tmp/npmrc",
GIT_CONFIG_GLOBAL: "/tmp/gitconfig",
SHELLOPTS: "xtrace",
PS4: "$(touch /tmp/pwned)",
SAFE: "ok",
@@ -65,6 +69,10 @@ describe("sanitizeHostExecEnv", () => {
expect(env.PATH).toBe("/usr/bin:/bin");
expect(env.BASH_ENV).toBeUndefined();
expect(env.GIT_SSH_COMMAND).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");
@@ -110,6 +118,10 @@ 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);
});
@@ -192,3 +204,58 @@ describe("shell wrapper exploit regression", () => {
expect(fs.existsSync(marker)).toBe(false);
});
});
describe("git env exploit regression", () => {
it("blocks GIT_SSH_COMMAND override so git cannot execute helper payloads", async () => {
if (process.platform === "win32") {
return;
}
const gitPath = "/usr/bin/git";
if (!fs.existsSync(gitPath)) {
return;
}
const marker = path.join(os.tmpdir(), `openclaw-git-ssh-command-${process.pid}-${Date.now()}`);
try {
fs.unlinkSync(marker);
} catch {
// no-op
}
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 new Promise<void>((resolve) => {
const child = spawn(gitPath, ["ls-remote", target], { env: unsafeEnv, stdio: "ignore" });
child.once("error", () => resolve());
child.once("close", () => resolve());
});
expect(fs.existsSync(marker)).toBe(true);
fs.unlinkSync(marker);
const safeEnv = sanitizeHostExecEnv({
baseEnv,
overrides: {
GIT_SSH_COMMAND: exploitValue,
},
});
await new Promise<void>((resolve) => {
const child = spawn(gitPath, ["ls-remote", target], { env: safeEnv, stdio: "ignore" });
child.once("error", () => resolve());
child.once("close", () => resolve());
});
expect(fs.existsSync(marker)).toBe(false);
});
});

View File

@@ -5,6 +5,7 @@ const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
type HostEnvSecurityPolicy = {
blockedKeys: string[];
blockedOverrideKeys?: string[];
blockedOverridePrefixes?: string[];
blockedPrefixes: string[];
};
@@ -19,6 +20,9 @@ export const HOST_DANGEROUS_ENV_PREFIXES: readonly string[] = Object.freeze(
export const HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze(
(HOST_ENV_SECURITY_POLICY.blockedOverrideKeys ?? []).map((key) => key.toUpperCase()),
);
export const HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES: readonly string[] = Object.freeze(
(HOST_ENV_SECURITY_POLICY.blockedOverridePrefixes ?? []).map((prefix) => prefix.toUpperCase()),
);
export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze([
"TERM",
"LANG",
@@ -68,7 +72,11 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean {
if (!key) {
return false;
}
return HOST_DANGEROUS_OVERRIDE_ENV_KEYS.has(key.toUpperCase());
const upper = key.toUpperCase();
if (HOST_DANGEROUS_OVERRIDE_ENV_KEYS.has(upper)) {
return true;
}
return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
}
export function sanitizeHostExecEnv(params?: {