mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:21:23 +00:00
fix: harden exec allowlist wrapper resolution
This commit is contained in:
@@ -12,6 +12,106 @@ export type CommandResolution = {
|
||||
executableName: string;
|
||||
};
|
||||
|
||||
const ENV_OPTIONS_WITH_VALUE = new Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
]);
|
||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
||||
|
||||
function basenameLower(token: string): string {
|
||||
const win = path.win32.basename(token);
|
||||
const posix = path.posix.basename(token);
|
||||
const base = win.length < posix.length ? win : posix;
|
||||
return base.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isEnvAssignment(token: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
||||
}
|
||||
|
||||
function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--" || token === "-") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (isEnvAssignment(token)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=")) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower.startsWith("-u") ||
|
||||
lower.startsWith("-c") ||
|
||||
lower.startsWith("-s") ||
|
||||
lower.startsWith("--unset=") ||
|
||||
lower.startsWith("--chdir=") ||
|
||||
lower.startsWith("--split-string=") ||
|
||||
lower.startsWith("--default-signal=") ||
|
||||
lower.startsWith("--ignore-signal=") ||
|
||||
lower.startsWith("--block-signal=")
|
||||
) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function unwrapDispatchWrappersForResolution(argv: string[]): string[] {
|
||||
let current = argv;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
const token0 = current[0]?.trim();
|
||||
if (!token0) {
|
||||
break;
|
||||
}
|
||||
if (basenameLower(token0) !== "env") {
|
||||
break;
|
||||
}
|
||||
const unwrapped = unwrapEnvInvocation(current);
|
||||
if (!unwrapped || unwrapped.length === 0) {
|
||||
break;
|
||||
}
|
||||
current = unwrapped;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
@@ -101,7 +201,8 @@ export function resolveCommandResolutionFromArgv(
|
||||
cwd?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): CommandResolution | null {
|
||||
const rawExecutable = argv[0]?.trim();
|
||||
const effectiveArgv = unwrapDispatchWrappersForResolution(argv);
|
||||
const rawExecutable = effectiveArgv[0]?.trim();
|
||||
if (!rawExecutable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
normalizeSafeBins,
|
||||
requiresExecApproval,
|
||||
resolveCommandResolution,
|
||||
resolveCommandResolutionFromArgv,
|
||||
resolveAllowAlwaysPatterns,
|
||||
resolveExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
@@ -241,6 +242,30 @@ describe("exec approvals command resolution", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps env wrapper argv to resolve the effective executable", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "rg.exe" : "rg";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
|
||||
const resolution = resolveCommandResolutionFromArgv(
|
||||
["/usr/bin/env", "FOO=bar", "rg", "-n", "needle"],
|
||||
undefined,
|
||||
makePathEnv(binDir),
|
||||
);
|
||||
expect(resolution?.resolvedPath).toBe(exe);
|
||||
expect(resolution?.executableName).toBe(exeName);
|
||||
});
|
||||
|
||||
it("unwraps env wrapper with shell inner executable", () => {
|
||||
const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]);
|
||||
expect(resolution?.rawExecutable).toBe("bash");
|
||||
expect(resolution?.executableName.toLowerCase()).toContain("bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals shell parsing", () => {
|
||||
|
||||
@@ -29,6 +29,25 @@ describe("system run command helpers", () => {
|
||||
expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "/c", "echo hi"])).toBe("echo hi");
|
||||
});
|
||||
|
||||
test("extractShellCommandFromArgv unwraps /usr/bin/env shell wrappers", () => {
|
||||
expect(extractShellCommandFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"])).toBe("echo hi");
|
||||
expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "zsh", "-c", "echo hi"])).toBe(
|
||||
"echo hi",
|
||||
);
|
||||
});
|
||||
|
||||
test("extractShellCommandFromArgv supports fish and pwsh wrappers", () => {
|
||||
expect(extractShellCommandFromArgv(["fish", "-c", "echo hi"])).toBe("echo hi");
|
||||
expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date");
|
||||
});
|
||||
|
||||
test("extractShellCommandFromArgv ignores env wrappers when no shell wrapper follows", () => {
|
||||
expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"])).toBe(
|
||||
null,
|
||||
);
|
||||
expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar"])).toBe(null);
|
||||
});
|
||||
|
||||
test("extractShellCommandFromArgv includes trailing cmd.exe args after /c", () => {
|
||||
expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"])).toBe(
|
||||
"echo SAFE&&whoami",
|
||||
@@ -63,6 +82,14 @@ describe("system run command helpers", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("validateSystemRunCommandConsistency accepts rawCommand matching env shell wrapper argv", () => {
|
||||
const res = validateSystemRunCommandConsistency({
|
||||
argv: ["/usr/bin/env", "bash", "-lc", "echo hi"],
|
||||
rawCommand: "echo hi",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("validateSystemRunCommandConsistency rejects cmd.exe /c trailing-arg smuggling", () => {
|
||||
expectRawCommandMismatch({
|
||||
argv: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
|
||||
|
||||
@@ -33,6 +33,156 @@ function basenameLower(token: string): string {
|
||||
return base.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
|
||||
const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
|
||||
const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
const ENV_OPTIONS_WITH_VALUE = new Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
]);
|
||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
||||
|
||||
function isEnvAssignment(token: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
||||
}
|
||||
|
||||
function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--" || token === "-") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (isEnvAssignment(token)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=")) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower.startsWith("-u") ||
|
||||
lower.startsWith("-c") ||
|
||||
lower.startsWith("-s") ||
|
||||
lower.startsWith("--unset=") ||
|
||||
lower.startsWith("--chdir=") ||
|
||||
lower.startsWith("--split-string=") ||
|
||||
lower.startsWith("--default-signal=") ||
|
||||
lower.startsWith("--ignore-signal=") ||
|
||||
lower.startsWith("--block-signal=")
|
||||
) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function extractPosixShellInlineCommand(argv: string[]): string | null {
|
||||
const flag = argv[1]?.trim();
|
||||
if (!flag) {
|
||||
return null;
|
||||
}
|
||||
const lower = flag.toLowerCase();
|
||||
if (lower !== "-lc" && lower !== "-c" && lower !== "--command") {
|
||||
return null;
|
||||
}
|
||||
const cmd = argv[2]?.trim();
|
||||
return cmd ? cmd : null;
|
||||
}
|
||||
|
||||
function extractCmdInlineCommand(argv: string[]): string | null {
|
||||
const idx = argv.findIndex((item) => String(item).trim().toLowerCase() === "/c");
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx + 1).map((item) => String(item));
|
||||
if (tail.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const cmd = tail.join(" ").trim();
|
||||
return cmd.length > 0 ? cmd : null;
|
||||
}
|
||||
|
||||
function extractPowerShellInlineCommand(argv: string[]): string | null {
|
||||
for (let i = 1; i < argv.length; i += 1) {
|
||||
const token = argv[i]?.trim();
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const lower = token.toLowerCase();
|
||||
if (lower === "--") {
|
||||
break;
|
||||
}
|
||||
if (lower === "-c" || lower === "-command" || lower === "--command") {
|
||||
const cmd = argv[i + 1]?.trim();
|
||||
return cmd ? cmd : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractShellCommandFromArgvInternal(argv: string[], depth: number): string | null {
|
||||
if (depth >= 4) {
|
||||
return null;
|
||||
}
|
||||
const token0 = argv[0]?.trim();
|
||||
if (!token0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base0 = basenameLower(token0);
|
||||
if (base0 === "env") {
|
||||
const unwrapped = unwrapEnvInvocation(argv);
|
||||
if (!unwrapped) {
|
||||
return null;
|
||||
}
|
||||
return extractShellCommandFromArgvInternal(unwrapped, depth + 1);
|
||||
}
|
||||
if (POSIX_SHELL_WRAPPERS.has(base0)) {
|
||||
return extractPosixShellInlineCommand(argv);
|
||||
}
|
||||
if (WINDOWS_CMD_WRAPPERS.has(base0)) {
|
||||
return extractCmdInlineCommand(argv);
|
||||
}
|
||||
if (POWERSHELL_WRAPPERS.has(base0)) {
|
||||
return extractPowerShellInlineCommand(argv);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatExecCommand(argv: string[]): string {
|
||||
return argv
|
||||
.map((arg) => {
|
||||
@@ -50,44 +200,7 @@ export function formatExecCommand(argv: string[]): string {
|
||||
}
|
||||
|
||||
export function extractShellCommandFromArgv(argv: string[]): string | null {
|
||||
const token0 = argv[0]?.trim();
|
||||
if (!token0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base0 = basenameLower(token0);
|
||||
|
||||
// POSIX-style shells: sh -lc "<cmd>"
|
||||
if (
|
||||
base0 === "sh" ||
|
||||
base0 === "bash" ||
|
||||
base0 === "zsh" ||
|
||||
base0 === "dash" ||
|
||||
base0 === "ksh"
|
||||
) {
|
||||
const flag = argv[1]?.trim();
|
||||
if (flag !== "-lc" && flag !== "-c") {
|
||||
return null;
|
||||
}
|
||||
const cmd = argv[2];
|
||||
return typeof cmd === "string" ? cmd : null;
|
||||
}
|
||||
|
||||
// Windows cmd.exe: cmd.exe /d /s /c "<cmd>"
|
||||
if (base0 === "cmd.exe" || base0 === "cmd") {
|
||||
const idx = argv.findIndex((item) => String(item).trim().toLowerCase() === "/c");
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx + 1).map((item) => String(item));
|
||||
if (tail.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const cmd = tail.join(" ").trim();
|
||||
return cmd.length > 0 ? cmd : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
return extractShellCommandFromArgvInternal(argv, 0);
|
||||
}
|
||||
|
||||
export function validateSystemRunCommandConsistency(params: {
|
||||
|
||||
Reference in New Issue
Block a user