mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:31:23 +00:00
chore: merge origin/main into main
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
||||
} from "./exec-approvals-analysis.js";
|
||||
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
||||
import {
|
||||
SAFE_BIN_GENERIC_PROFILE,
|
||||
SAFE_BIN_PROFILES,
|
||||
type SafeBinProfile,
|
||||
validateSafeBinArgv,
|
||||
@@ -41,7 +40,6 @@ export function isSafeBinUsage(params: {
|
||||
platform?: string | null;
|
||||
trustedSafeBinDirs?: ReadonlySet<string>;
|
||||
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
|
||||
safeBinGenericProfile?: SafeBinProfile;
|
||||
isTrustedSafeBinPathFn?: typeof isTrustedSafeBinPath;
|
||||
}): boolean {
|
||||
// Windows host exec uses PowerShell, which has different parsing/expansion rules.
|
||||
@@ -75,8 +73,10 @@ export function isSafeBinUsage(params: {
|
||||
}
|
||||
const argv = params.argv.slice(1);
|
||||
const safeBinProfiles = params.safeBinProfiles ?? SAFE_BIN_PROFILES;
|
||||
const genericSafeBinProfile = params.safeBinGenericProfile ?? SAFE_BIN_GENERIC_PROFILE;
|
||||
const profile = safeBinProfiles[execName] ?? genericSafeBinProfile;
|
||||
const profile = safeBinProfiles[execName];
|
||||
if (!profile) {
|
||||
return false;
|
||||
}
|
||||
return validateSafeBinArgv(argv, profile);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ function evaluateSegments(
|
||||
params: {
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
safeBins: Set<string>;
|
||||
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
|
||||
cwd?: string;
|
||||
platform?: string | null;
|
||||
trustedSafeBinDirs?: ReadonlySet<string>;
|
||||
@@ -122,6 +123,7 @@ function evaluateSegments(
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: params.safeBins,
|
||||
safeBinProfiles: params.safeBinProfiles,
|
||||
platform: params.platform,
|
||||
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
||||
});
|
||||
@@ -147,6 +149,7 @@ export function evaluateExecAllowlist(params: {
|
||||
analysis: ExecCommandAnalysis;
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
safeBins: Set<string>;
|
||||
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
|
||||
cwd?: string;
|
||||
platform?: string | null;
|
||||
trustedSafeBinDirs?: ReadonlySet<string>;
|
||||
@@ -165,6 +168,7 @@ export function evaluateExecAllowlist(params: {
|
||||
const result = evaluateSegments(chainSegments, {
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
safeBinProfiles: params.safeBinProfiles,
|
||||
cwd: params.cwd,
|
||||
platform: params.platform,
|
||||
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
||||
@@ -184,6 +188,7 @@ export function evaluateExecAllowlist(params: {
|
||||
const result = evaluateSegments(params.analysis.segments, {
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
safeBinProfiles: params.safeBinProfiles,
|
||||
cwd: params.cwd,
|
||||
platform: params.platform,
|
||||
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
||||
@@ -354,6 +359,7 @@ export function evaluateShellAllowlist(params: {
|
||||
command: string;
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
safeBins: Set<string>;
|
||||
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
trustedSafeBinDirs?: ReadonlySet<string>;
|
||||
@@ -384,6 +390,7 @@ export function evaluateShellAllowlist(params: {
|
||||
analysis,
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
safeBinProfiles: params.safeBinProfiles,
|
||||
cwd: params.cwd,
|
||||
platform: params.platform,
|
||||
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
||||
@@ -419,6 +426,7 @@ export function evaluateShellAllowlist(params: {
|
||||
analysis,
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
safeBinProfiles: params.safeBinProfiles,
|
||||
cwd: params.cwd,
|
||||
platform: params.platform,
|
||||
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
type ExecAllowlistEntry,
|
||||
type ExecApprovalsFile,
|
||||
} from "./exec-approvals.js";
|
||||
import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES } from "./exec-safe-bin-policy.js";
|
||||
import {
|
||||
SAFE_BIN_PROFILE_FIXTURES,
|
||||
SAFE_BIN_PROFILES,
|
||||
resolveSafeBinProfiles,
|
||||
} from "./exec-safe-bin-policy.js";
|
||||
|
||||
function makePathEnv(binDir: string): NodeJS.ProcessEnv {
|
||||
if (process.platform !== "win32") {
|
||||
@@ -798,6 +802,53 @@ describe("exec approvals safe bins", () => {
|
||||
expect(defaults.has("grep")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not auto-allow unprofiled safe-bin entries", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const result = evaluateShellAllowlist({
|
||||
command: "python3 -c \"print('owned')\"",
|
||||
allowlist: [],
|
||||
safeBins: normalizeSafeBins(["python3"]),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(result.analysisOk).toBe(true);
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
});
|
||||
|
||||
it("allows caller-defined custom safe-bin profiles", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const safeBinProfiles = resolveSafeBinProfiles({
|
||||
echo: {
|
||||
maxPositional: 1,
|
||||
},
|
||||
});
|
||||
const allow = isSafeBinUsage({
|
||||
argv: ["echo", "hello"],
|
||||
resolution: {
|
||||
rawExecutable: "echo",
|
||||
resolvedPath: "/bin/echo",
|
||||
executableName: "echo",
|
||||
},
|
||||
safeBins: normalizeSafeBins(["echo"]),
|
||||
safeBinProfiles,
|
||||
});
|
||||
const deny = isSafeBinUsage({
|
||||
argv: ["echo", "hello", "world"],
|
||||
resolution: {
|
||||
rawExecutable: "echo",
|
||||
resolvedPath: "/bin/echo",
|
||||
executableName: "echo",
|
||||
},
|
||||
safeBins: normalizeSafeBins(["echo"]),
|
||||
safeBinProfiles,
|
||||
});
|
||||
expect(allow).toBe(true);
|
||||
expect(deny).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks sort output flags independent of file existence", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
|
||||
@@ -37,6 +37,8 @@ export type SafeBinProfileFixture = {
|
||||
deniedFlags?: readonly string[];
|
||||
};
|
||||
|
||||
export type SafeBinProfileFixtures = Readonly<Record<string, SafeBinProfileFixture>>;
|
||||
|
||||
const NO_FLAGS: ReadonlySet<string> = new Set();
|
||||
|
||||
const toFlagSet = (flags?: readonly string[]): ReadonlySet<string> => {
|
||||
@@ -63,8 +65,6 @@ function compileSafeBinProfiles(
|
||||
) as Record<string, SafeBinProfile>;
|
||||
}
|
||||
|
||||
export const SAFE_BIN_GENERIC_PROFILE_FIXTURE: SafeBinProfileFixture = {};
|
||||
|
||||
export const SAFE_BIN_PROFILE_FIXTURES: Record<string, SafeBinProfileFixture> = {
|
||||
jq: {
|
||||
maxPositional: 1,
|
||||
@@ -184,11 +184,81 @@ export const SAFE_BIN_PROFILE_FIXTURES: Record<string, SafeBinProfileFixture> =
|
||||
},
|
||||
};
|
||||
|
||||
export const SAFE_BIN_GENERIC_PROFILE = compileSafeBinProfile(SAFE_BIN_GENERIC_PROFILE_FIXTURE);
|
||||
|
||||
export const SAFE_BIN_PROFILES: Record<string, SafeBinProfile> =
|
||||
compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES);
|
||||
|
||||
function normalizeSafeBinProfileName(raw: string): string | null {
|
||||
const name = raw.trim().toLowerCase();
|
||||
return name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
function normalizeFixtureLimit(raw: number | undefined): number | undefined {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const next = Math.trunc(raw);
|
||||
return next >= 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function normalizeFixtureFlags(
|
||||
flags: readonly string[] | undefined,
|
||||
): readonly string[] | undefined {
|
||||
if (!Array.isArray(flags) || flags.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = Array.from(
|
||||
new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)),
|
||||
).toSorted((a, b) => a.localeCompare(b));
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture {
|
||||
const minPositional = normalizeFixtureLimit(fixture.minPositional);
|
||||
const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional);
|
||||
const maxPositional =
|
||||
minPositional !== undefined &&
|
||||
maxPositionalRaw !== undefined &&
|
||||
maxPositionalRaw < minPositional
|
||||
? minPositional
|
||||
: maxPositionalRaw;
|
||||
return {
|
||||
minPositional,
|
||||
maxPositional,
|
||||
allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags),
|
||||
deniedFlags: normalizeFixtureFlags(fixture.deniedFlags),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSafeBinProfileFixtures(
|
||||
fixtures?: SafeBinProfileFixtures | null,
|
||||
): Record<string, SafeBinProfileFixture> {
|
||||
const normalized: Record<string, SafeBinProfileFixture> = {};
|
||||
if (!fixtures) {
|
||||
return normalized;
|
||||
}
|
||||
for (const [rawName, fixture] of Object.entries(fixtures)) {
|
||||
const name = normalizeSafeBinProfileName(rawName);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
normalized[name] = normalizeSafeBinProfileFixture(fixture);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveSafeBinProfiles(
|
||||
fixtures?: SafeBinProfileFixtures | null,
|
||||
): Record<string, SafeBinProfile> {
|
||||
const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures);
|
||||
if (Object.keys(normalizedFixtures).length === 0) {
|
||||
return SAFE_BIN_PROFILES;
|
||||
}
|
||||
return {
|
||||
...SAFE_BIN_PROFILES,
|
||||
...compileSafeBinProfiles(normalizedFixtures),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSafeBinDeniedFlags(
|
||||
fixtures: Readonly<Record<string, SafeBinProfileFixture>> = SAFE_BIN_PROFILE_FIXTURES,
|
||||
): Record<string, string[]> {
|
||||
|
||||
73
src/infra/exec-safe-bin-runtime-policy.test.ts
Normal file
73
src/infra/exec-safe-bin-runtime-policy.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isInterpreterLikeSafeBin,
|
||||
listInterpreterLikeSafeBins,
|
||||
resolveExecSafeBinRuntimePolicy,
|
||||
resolveMergedSafeBinProfileFixtures,
|
||||
} from "./exec-safe-bin-runtime-policy.js";
|
||||
|
||||
describe("exec safe-bin runtime policy", () => {
|
||||
const interpreterCases: Array<{ bin: string; expected: boolean }> = [
|
||||
{ bin: "python3", expected: true },
|
||||
{ bin: "python3.12", expected: true },
|
||||
{ bin: "node", expected: true },
|
||||
{ bin: "node20", expected: true },
|
||||
{ bin: "ruby3.2", expected: true },
|
||||
{ bin: "bash", expected: true },
|
||||
{ bin: "myfilter", expected: false },
|
||||
{ bin: "jq", expected: false },
|
||||
];
|
||||
|
||||
for (const testCase of interpreterCases) {
|
||||
it(`classifies interpreter-like safe bin '${testCase.bin}'`, () => {
|
||||
expect(isInterpreterLikeSafeBin(testCase.bin)).toBe(testCase.expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("lists interpreter-like bins from a mixed set", () => {
|
||||
expect(listInterpreterLikeSafeBins(["jq", "python3", "myfilter", "node"])).toEqual([
|
||||
"node",
|
||||
"python3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges and normalizes safe-bin profile fixtures", () => {
|
||||
const merged = resolveMergedSafeBinProfileFixtures({
|
||||
global: {
|
||||
safeBinProfiles: {
|
||||
" MyFilter ": {
|
||||
deniedFlags: ["--file", " --file ", ""],
|
||||
},
|
||||
},
|
||||
},
|
||||
local: {
|
||||
safeBinProfiles: {
|
||||
myfilter: {
|
||||
maxPositional: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(merged).toEqual({
|
||||
myfilter: {
|
||||
maxPositional: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("computes unprofiled interpreter entries separately from custom profiled bins", () => {
|
||||
const policy = resolveExecSafeBinRuntimePolicy({
|
||||
local: {
|
||||
safeBins: ["python3", "myfilter"],
|
||||
safeBinProfiles: {
|
||||
myfilter: { maxPositional: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(policy.safeBins.has("python3")).toBe(true);
|
||||
expect(policy.safeBins.has("myfilter")).toBe(true);
|
||||
expect(policy.unprofiledSafeBins).toEqual(["python3"]);
|
||||
expect(policy.unprofiledInterpreterSafeBins).toEqual(["python3"]);
|
||||
});
|
||||
});
|
||||
127
src/infra/exec-safe-bin-runtime-policy.ts
Normal file
127
src/infra/exec-safe-bin-runtime-policy.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { resolveSafeBins } from "./exec-approvals-allowlist.js";
|
||||
import {
|
||||
normalizeSafeBinProfileFixtures,
|
||||
resolveSafeBinProfiles,
|
||||
type SafeBinProfile,
|
||||
type SafeBinProfileFixture,
|
||||
type SafeBinProfileFixtures,
|
||||
} from "./exec-safe-bin-policy.js";
|
||||
import { getTrustedSafeBinDirs } from "./exec-safe-bin-trust.js";
|
||||
|
||||
export type ExecSafeBinConfigScope = {
|
||||
safeBins?: string[] | null;
|
||||
safeBinProfiles?: SafeBinProfileFixtures | null;
|
||||
};
|
||||
|
||||
const INTERPRETER_LIKE_SAFE_BINS = new Set([
|
||||
"ash",
|
||||
"bash",
|
||||
"bun",
|
||||
"cmd",
|
||||
"cmd.exe",
|
||||
"cscript",
|
||||
"dash",
|
||||
"deno",
|
||||
"fish",
|
||||
"ksh",
|
||||
"lua",
|
||||
"node",
|
||||
"nodejs",
|
||||
"perl",
|
||||
"php",
|
||||
"powershell",
|
||||
"powershell.exe",
|
||||
"pypy",
|
||||
"pwsh",
|
||||
"pwsh.exe",
|
||||
"python",
|
||||
"python2",
|
||||
"python3",
|
||||
"ruby",
|
||||
"sh",
|
||||
"wscript",
|
||||
"zsh",
|
||||
]);
|
||||
|
||||
const INTERPRETER_LIKE_PATTERNS = [
|
||||
/^python\d+(?:\.\d+)?$/,
|
||||
/^ruby\d+(?:\.\d+)?$/,
|
||||
/^perl\d+(?:\.\d+)?$/,
|
||||
/^php\d+(?:\.\d+)?$/,
|
||||
/^node\d+(?:\.\d+)?$/,
|
||||
];
|
||||
|
||||
function normalizeSafeBinName(raw: string): string {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const tail = trimmed.split(/[\\/]/).at(-1);
|
||||
return tail ?? trimmed;
|
||||
}
|
||||
|
||||
export function isInterpreterLikeSafeBin(raw: string): boolean {
|
||||
const normalized = normalizeSafeBinName(raw);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (INTERPRETER_LIKE_SAFE_BINS.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return INTERPRETER_LIKE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
export function listInterpreterLikeSafeBins(entries: Iterable<string>): string[] {
|
||||
return Array.from(entries)
|
||||
.map((entry) => normalizeSafeBinName(entry))
|
||||
.filter((entry) => entry.length > 0 && isInterpreterLikeSafeBin(entry))
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
export function resolveMergedSafeBinProfileFixtures(params: {
|
||||
global?: ExecSafeBinConfigScope | null;
|
||||
local?: ExecSafeBinConfigScope | null;
|
||||
}): Record<string, SafeBinProfileFixture> | undefined {
|
||||
const global = normalizeSafeBinProfileFixtures(params.global?.safeBinProfiles);
|
||||
const local = normalizeSafeBinProfileFixtures(params.local?.safeBinProfiles);
|
||||
if (Object.keys(global).length === 0 && Object.keys(local).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...global,
|
||||
...local,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExecSafeBinRuntimePolicy(params: {
|
||||
global?: ExecSafeBinConfigScope | null;
|
||||
local?: ExecSafeBinConfigScope | null;
|
||||
pathEnv?: string | null;
|
||||
}): {
|
||||
safeBins: Set<string>;
|
||||
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
|
||||
trustedSafeBinDirs: ReadonlySet<string>;
|
||||
unprofiledSafeBins: string[];
|
||||
unprofiledInterpreterSafeBins: string[];
|
||||
} {
|
||||
const safeBins = resolveSafeBins(params.local?.safeBins ?? params.global?.safeBins);
|
||||
const safeBinProfiles = resolveSafeBinProfiles(
|
||||
resolveMergedSafeBinProfileFixtures({
|
||||
global: params.global,
|
||||
local: params.local,
|
||||
}),
|
||||
);
|
||||
const unprofiledSafeBins = Array.from(safeBins)
|
||||
.filter((entry) => !safeBinProfiles[entry])
|
||||
.toSorted();
|
||||
const trustedSafeBinDirs = params.pathEnv
|
||||
? getTrustedSafeBinDirs({ pathEnv: params.pathEnv })
|
||||
: getTrustedSafeBinDirs();
|
||||
return {
|
||||
safeBins,
|
||||
safeBinProfiles,
|
||||
trustedSafeBinDirs,
|
||||
unprofiledSafeBins,
|
||||
unprofiledInterpreterSafeBins: listInterpreterLikeSafeBins(unprofiledSafeBins),
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"SHELL",
|
||||
"SHELLOPTS",
|
||||
"PS4",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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";
|
||||
|
||||
describe("isDangerousHostEnvVarName", () => {
|
||||
@@ -11,6 +16,8 @@ describe("isDangerousHostEnvVarName", () => {
|
||||
expect(isDangerousHostEnvVarName("BASH_ENV")).toBe(true);
|
||||
expect(isDangerousHostEnvVarName("bash_env")).toBe(true);
|
||||
expect(isDangerousHostEnvVarName("SHELL")).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);
|
||||
@@ -48,17 +55,37 @@ describe("sanitizeHostExecEnv", () => {
|
||||
HOME: "/tmp/evil-home",
|
||||
ZDOTDIR: "/tmp/evil-zdotdir",
|
||||
BASH_ENV: "/tmp/pwn.sh",
|
||||
SHELLOPTS: "xtrace",
|
||||
PS4: "$(touch /tmp/pwned)",
|
||||
SAFE: "ok",
|
||||
},
|
||||
});
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin:/bin");
|
||||
expect(env.BASH_ENV).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.OK).toBe("1");
|
||||
expect(env.SHELLOPTS).toBeUndefined();
|
||||
expect(env.PS4).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops non-portable env key names", () => {
|
||||
const env = sanitizeHostExecEnv({
|
||||
baseEnv: {
|
||||
@@ -94,3 +121,72 @@ describe("normalizeEnvVarKey", () => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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<void>((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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,23 @@ 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_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze([
|
||||
"TERM",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LC_MESSAGES",
|
||||
"COLORTERM",
|
||||
"NO_COLOR",
|
||||
"FORCE_COLOR",
|
||||
]);
|
||||
export const HOST_DANGEROUS_ENV_KEYS = new Set<string>(HOST_DANGEROUS_ENV_KEY_VALUES);
|
||||
export const HOST_DANGEROUS_OVERRIDE_ENV_KEYS = new Set<string>(
|
||||
HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES,
|
||||
);
|
||||
export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set<string>(
|
||||
HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES,
|
||||
);
|
||||
|
||||
export function normalizeEnvVarKey(
|
||||
rawKey: string,
|
||||
@@ -105,3 +118,31 @@ export function sanitizeHostExecEnv(params?: {
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function sanitizeSystemRunEnvOverrides(params?: {
|
||||
overrides?: Record<string, string> | null;
|
||||
shellWrapper?: boolean;
|
||||
}): Record<string, string> | undefined {
|
||||
const overrides = params?.overrides ?? undefined;
|
||||
if (!overrides) {
|
||||
return undefined;
|
||||
}
|
||||
if (!params?.shellWrapper) {
|
||||
return overrides;
|
||||
}
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) {
|
||||
continue;
|
||||
}
|
||||
filtered[key] = value;
|
||||
}
|
||||
return Object.keys(filtered).length > 0 ? filtered : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user