mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 08:01:23 +00:00
fix(security): harden shell env fallback
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"SHELL",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE"
|
||||
|
||||
@@ -9,6 +9,7 @@ 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("DYLD_INSERT_LIBRARIES")).toBe(true);
|
||||
expect(isDangerousHostEnvVarName("ld_preload")).toBe(true);
|
||||
expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true);
|
||||
|
||||
@@ -121,6 +121,38 @@ describe("shell env fallback", () => {
|
||||
expect(exec).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh when SHELL is non-absolute", () => {
|
||||
const env: NodeJS.ProcessEnv = { SHELL: "zsh" };
|
||||
const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0"));
|
||||
|
||||
const res = loadShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh when SHELL points to an untrusted path", () => {
|
||||
const env: NodeJS.ProcessEnv = { SHELL: "/tmp/evil-shell" };
|
||||
const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0"));
|
||||
|
||||
const res = loadShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("returns null without invoking shell on win32", () => {
|
||||
resetShellPathCacheForTests();
|
||||
const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0"));
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isTruthyEnvValue } from "./env.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_SHELL = "/bin/sh";
|
||||
const TRUSTED_SHELL_PREFIXES = [
|
||||
"/bin/",
|
||||
"/usr/bin/",
|
||||
"/usr/local/bin/",
|
||||
"/opt/homebrew/bin/",
|
||||
"/run/current-system/sw/bin/",
|
||||
];
|
||||
let lastAppliedKeys: string[] = [];
|
||||
let cachedShellPath: string | null | undefined;
|
||||
let cachedEtcShells: Set<string> | null | undefined;
|
||||
|
||||
function resolveTimeoutMs(timeoutMs: number | undefined): number {
|
||||
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
|
||||
@@ -13,9 +24,57 @@ function resolveTimeoutMs(timeoutMs: number | undefined): number {
|
||||
return Math.max(0, timeoutMs);
|
||||
}
|
||||
|
||||
function readEtcShells(): Set<string> | null {
|
||||
if (cachedEtcShells !== undefined) {
|
||||
return cachedEtcShells;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync("/etc/shells", "utf8");
|
||||
const entries = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#") && path.isAbsolute(line));
|
||||
cachedEtcShells = new Set(entries);
|
||||
} catch {
|
||||
cachedEtcShells = null;
|
||||
}
|
||||
return cachedEtcShells;
|
||||
}
|
||||
|
||||
function isTrustedShellPath(shell: string): boolean {
|
||||
if (!path.isAbsolute(shell)) {
|
||||
return false;
|
||||
}
|
||||
const normalized = path.normalize(shell);
|
||||
if (normalized !== shell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Primary trust anchor: shell registered in /etc/shells.
|
||||
const registeredShells = readEtcShells();
|
||||
if (registeredShells?.has(shell)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback for environments where /etc/shells is incomplete/unavailable.
|
||||
if (!TRUSTED_SHELL_PREFIXES.some((prefix) => shell.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(shell, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveShell(env: NodeJS.ProcessEnv): string {
|
||||
const shell = env.SHELL?.trim();
|
||||
return shell && shell.length > 0 ? shell : "/bin/sh";
|
||||
if (shell && isTrustedShellPath(shell)) {
|
||||
return shell;
|
||||
}
|
||||
return DEFAULT_SHELL;
|
||||
}
|
||||
|
||||
function execLoginShellEnvZero(params: {
|
||||
@@ -171,6 +230,7 @@ export function getShellPathFromLoginShell(opts: {
|
||||
|
||||
export function resetShellPathCacheForTests(): void {
|
||||
cachedShellPath = undefined;
|
||||
cachedEtcShells = undefined;
|
||||
}
|
||||
|
||||
export function getShellEnvAppliedKeys(): string[] {
|
||||
|
||||
Reference in New Issue
Block a user