mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(security): block startup-file env injection across host execution paths
This commit is contained in:
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.
|
||||
- Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.
|
||||
- Security/Browser: block non-network browser navigation protocols (including `file:`, `data:`, and `javascript:`) while preserving `about:blank`, preventing local file reads via browser tool navigation. This ships in the next npm release. Thanks @q1uf3ng for reporting.
|
||||
- Security/Exec: block shell startup-file env injection (`BASH_ENV`, `ENV`, `BASH_FUNC_*`, `LD_*`, `DYLD_*`) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey.
|
||||
- Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo.
|
||||
- Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.
|
||||
- Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.
|
||||
|
||||
@@ -364,21 +364,6 @@ private enum ExecHostExecutor {
|
||||
let skillAllow: Bool
|
||||
}
|
||||
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYOPT",
|
||||
]
|
||||
|
||||
private static let blockedEnvPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
]
|
||||
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !command.isEmpty else {
|
||||
@@ -580,18 +565,8 @@ private enum ExecHostExecutor {
|
||||
error: nil)
|
||||
}
|
||||
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||
guard let overrides else { return nil }
|
||||
var merged = ProcessInfo.processInfo.environment
|
||||
for (rawKey, value) in overrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
if self.blockedEnvKeys.contains(upper) { continue }
|
||||
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] {
|
||||
HostEnvSanitizer.sanitize(overrides: overrides)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift
Normal file
54
apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
enum HostEnvSanitizer {
|
||||
private static let blockedKeys: Set<String> = [
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
]
|
||||
|
||||
private static let blockedPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
"BASH_FUNC_",
|
||||
]
|
||||
|
||||
private static func isBlocked(_ upperKey: String) -> Bool {
|
||||
if self.blockedKeys.contains(upperKey) { return true }
|
||||
return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) })
|
||||
}
|
||||
|
||||
static func sanitize(overrides: [String: String]?) -> [String: String] {
|
||||
var merged: [String: String] = [:]
|
||||
for (rawKey, value) in ProcessInfo.processInfo.environment {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
if self.isBlocked(upper) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
guard let overrides else { return merged }
|
||||
for (rawKey, value) in overrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
|
||||
// allow request-scoped PATH overrides from agents/gateways.
|
||||
if upper == "PATH" { continue }
|
||||
if self.isBlocked(upper) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
}
|
||||
@@ -862,33 +862,8 @@ extension MacNodeRuntime {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYOPT",
|
||||
]
|
||||
|
||||
private static let blockedEnvPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
]
|
||||
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||
guard let overrides else { return nil }
|
||||
var merged = ProcessInfo.processInfo.environment
|
||||
for (rawKey, value) in overrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
if self.blockedEnvKeys.contains(upper) { continue }
|
||||
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] {
|
||||
HostEnvSanitizer.sanitize(overrides: overrides)
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> OpenClawLocationMode {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
||||
import { mergePathPrepend } from "../infra/path-prepend.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
@@ -28,28 +29,6 @@ import {
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
|
||||
// Security: Blocklist of environment variables that could alter execution flow
|
||||
// or inject code when running on non-sandboxed hosts (Gateway/Node).
|
||||
const DANGEROUS_HOST_ENV_VARS = new Set([
|
||||
"LD_PRELOAD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_AUDIT",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH",
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PYTHONPATH",
|
||||
"PYTHONHOME",
|
||||
"RUBYLIB",
|
||||
"PERL5LIB",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
]);
|
||||
const DANGEROUS_HOST_ENV_PREFIXES = ["DYLD_", "LD_"];
|
||||
|
||||
// Centralized sanitization helper.
|
||||
// Throws an error if dangerous variables or PATH modifications are detected on the host.
|
||||
export function validateHostEnv(env: Record<string, string>): void {
|
||||
@@ -57,12 +36,7 @@ export function validateHostEnv(env: Record<string, string>): void {
|
||||
const upperKey = key.toUpperCase();
|
||||
|
||||
// 1. Block known dangerous variables (Fail Closed)
|
||||
if (DANGEROUS_HOST_ENV_PREFIXES.some((prefix) => upperKey.startsWith(prefix))) {
|
||||
throw new Error(
|
||||
`Security Violation: Environment variable '${key}' is forbidden during host execution.`,
|
||||
);
|
||||
}
|
||||
if (DANGEROUS_HOST_ENV_VARS.has(upperKey)) {
|
||||
if (isDangerousHostEnvVarName(upperKey)) {
|
||||
throw new Error(
|
||||
`Security Violation: Environment variable '${key}' is forbidden during host execution.`,
|
||||
);
|
||||
|
||||
@@ -351,6 +351,50 @@ describe("applySkillEnvOverrides", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks dangerous host env overrides even when declared", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const skillDir = path.join(workspaceDir, "skills", "dangerous-env-skill");
|
||||
await writeSkill({
|
||||
dir: skillDir,
|
||||
name: "dangerous-env-skill",
|
||||
description: "Needs env",
|
||||
metadata: '{"openclaw":{"requires":{"env":["BASH_ENV"]}}}',
|
||||
});
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
});
|
||||
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
delete process.env.BASH_ENV;
|
||||
|
||||
const restore = applySkillEnvOverrides({
|
||||
skills: entries,
|
||||
config: {
|
||||
skills: {
|
||||
entries: {
|
||||
"dangerous-env-skill": {
|
||||
env: {
|
||||
BASH_ENV: "/tmp/pwn.sh",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(process.env.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
restore();
|
||||
if (originalBashEnv === undefined) {
|
||||
expect(process.env.BASH_ENV).toBeUndefined();
|
||||
} else {
|
||||
expect(process.env.BASH_ENV).toBe(originalBashEnv);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("allows required env overrides from snapshots", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const skillDir = path.join(workspaceDir, "skills", "snapshot-env-skill");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { isDangerousHostEnvVarName } from "../../infra/host-env-security.js";
|
||||
import { sanitizeEnvVars, validateEnvVarValue } from "../sandbox/sanitize-env-vars.js";
|
||||
import { resolveSkillConfig } from "./config.js";
|
||||
import { resolveSkillKey } from "./frontmatter.js";
|
||||
@@ -13,18 +14,19 @@ type SanitizedSkillEnvOverrides = {
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
// Never allow skill env overrides that can alter runtime loader flags.
|
||||
const HARD_BLOCKED_SKILL_ENV_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^NODE_OPTIONS$/i,
|
||||
/^OPENSSL_CONF$/i,
|
||||
/^LD_PRELOAD$/i,
|
||||
/^DYLD_INSERT_LIBRARIES$/i,
|
||||
];
|
||||
// Always block skill env overrides that can alter runtime loading or host execution behavior.
|
||||
const SKILL_ALWAYS_BLOCKED_ENV_PATTERNS: ReadonlyArray<RegExp> = [/^OPENSSL_CONF$/i];
|
||||
|
||||
function matchesAnyPattern(value: string, patterns: readonly RegExp[]): boolean {
|
||||
return patterns.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isAlwaysBlockedSkillEnvKey(key: string): boolean {
|
||||
return (
|
||||
isDangerousHostEnvVarName(key) || matchesAnyPattern(key, SKILL_ALWAYS_BLOCKED_ENV_PATTERNS)
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeSkillEnvOverrides(params: {
|
||||
overrides: Record<string, string>;
|
||||
allowedSensitiveKeys: Set<string>;
|
||||
@@ -33,19 +35,22 @@ function sanitizeSkillEnvOverrides(params: {
|
||||
return { allowed: {}, blocked: [], warnings: [] };
|
||||
}
|
||||
|
||||
const result = sanitizeEnvVars(params.overrides, {
|
||||
customBlockedPatterns: HARD_BLOCKED_SKILL_ENV_PATTERNS,
|
||||
});
|
||||
const allowed = { ...result.allowed };
|
||||
const blocked: string[] = [];
|
||||
const result = sanitizeEnvVars(params.overrides);
|
||||
const allowed: Record<string, string> = {};
|
||||
const blocked = new Set<string>();
|
||||
const warnings = [...result.warnings];
|
||||
|
||||
for (const [key, value] of Object.entries(result.allowed)) {
|
||||
if (isAlwaysBlockedSkillEnvKey(key)) {
|
||||
blocked.add(key);
|
||||
continue;
|
||||
}
|
||||
allowed[key] = value;
|
||||
}
|
||||
|
||||
for (const key of result.blocked) {
|
||||
if (
|
||||
matchesAnyPattern(key, HARD_BLOCKED_SKILL_ENV_PATTERNS) ||
|
||||
!params.allowedSensitiveKeys.has(key)
|
||||
) {
|
||||
blocked.push(key);
|
||||
if (isAlwaysBlockedSkillEnvKey(key) || !params.allowedSensitiveKeys.has(key)) {
|
||||
blocked.add(key);
|
||||
continue;
|
||||
}
|
||||
const value = params.overrides[key];
|
||||
@@ -55,7 +60,7 @@ function sanitizeSkillEnvOverrides(params: {
|
||||
const warning = validateEnvVarValue(value);
|
||||
if (warning) {
|
||||
if (warning === "Contains null bytes") {
|
||||
blocked.push(key);
|
||||
blocked.add(key);
|
||||
continue;
|
||||
}
|
||||
warnings.push(`${key}: ${warning}`);
|
||||
@@ -63,7 +68,7 @@ function sanitizeSkillEnvOverrides(params: {
|
||||
allowed[key] = value;
|
||||
}
|
||||
|
||||
return { allowed, blocked, warnings };
|
||||
return { allowed, blocked: [...blocked], warnings };
|
||||
}
|
||||
|
||||
function applySkillConfigEnvOverrides(params: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadDotEnv } from "../infra/dotenv.js";
|
||||
import { resolveConfigEnvVars } from "./env-substitution.js";
|
||||
import { applyConfigEnvVars } from "./env-vars.js";
|
||||
import { applyConfigEnvVars, collectConfigEnvVars } from "./env-vars.js";
|
||||
import { withEnvOverride, withTempHome } from "./test-helpers.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
|
||||
@@ -29,6 +29,21 @@ describe("config env vars", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks dangerous startup env vars from config env", async () => {
|
||||
await withEnvOverride({ BASH_ENV: undefined, OPENROUTER_API_KEY: undefined }, async () => {
|
||||
const config = {
|
||||
env: { vars: { BASH_ENV: "/tmp/pwn.sh", OPENROUTER_API_KEY: "config-key" } },
|
||||
};
|
||||
const entries = collectConfigEnvVars(config as OpenClawConfig);
|
||||
expect(entries.BASH_ENV).toBeUndefined();
|
||||
expect(entries.OPENROUTER_API_KEY).toBe("config-key");
|
||||
|
||||
applyConfigEnvVars(config as OpenClawConfig);
|
||||
expect(process.env.BASH_ENV).toBeUndefined();
|
||||
expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
|
||||
});
|
||||
});
|
||||
|
||||
it("loads ${VAR} substitutions from ~/.openclaw/.env on repeated runtime loads", async () => {
|
||||
await withTempHome(async (_home) => {
|
||||
await withEnvOverride({ BRAVE_API_KEY: undefined }, async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
|
||||
export function collectConfigEnvVars(cfg?: OpenClawConfig): Record<string, string> {
|
||||
@@ -13,6 +14,9 @@ export function collectConfigEnvVars(cfg?: OpenClawConfig): Record<string, strin
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (isDangerousHostEnvVarName(key)) {
|
||||
continue;
|
||||
}
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +28,9 @@ export function collectConfigEnvVars(cfg?: OpenClawConfig): Record<string, strin
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (isDangerousHostEnvVarName(key)) {
|
||||
continue;
|
||||
}
|
||||
entries[key] = value;
|
||||
}
|
||||
|
||||
|
||||
51
src/infra/host-env-security.test.ts
Normal file
51
src/infra/host-env-security.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isDangerousHostEnvVarName, sanitizeHostExecEnv } from "./host-env-security.js";
|
||||
|
||||
describe("isDangerousHostEnvVarName", () => {
|
||||
it("matches dangerous keys and prefixes case-insensitively", () => {
|
||||
expect(isDangerousHostEnvVarName("BASH_ENV")).toBe(true);
|
||||
expect(isDangerousHostEnvVarName("bash_env")).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",
|
||||
LD_PRELOAD: "/tmp/pwn.so",
|
||||
OK: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(env).toEqual({
|
||||
PATH: "/usr/bin:/bin",
|
||||
OK: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks PATH and dangerous override values", () => {
|
||||
const env = sanitizeHostExecEnv({
|
||||
baseEnv: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
overrides: {
|
||||
PATH: "/tmp/evil",
|
||||
BASH_ENV: "/tmp/pwn.sh",
|
||||
SAFE: "ok",
|
||||
},
|
||||
});
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin:/bin");
|
||||
expect(env.BASH_ENV).toBeUndefined();
|
||||
expect(env.SAFE).toBe("ok");
|
||||
expect(env.HOME).toBe("/tmp/home");
|
||||
});
|
||||
});
|
||||
74
src/infra/host-env-security.ts
Normal file
74
src/infra/host-env-security.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
const HOST_DANGEROUS_ENV_KEY_VALUES = [
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
] as const;
|
||||
|
||||
export const HOST_DANGEROUS_ENV_KEYS = new Set<string>(HOST_DANGEROUS_ENV_KEY_VALUES);
|
||||
export const HOST_DANGEROUS_ENV_PREFIXES = ["DYLD_", "LD_", "BASH_FUNC_"] as const;
|
||||
|
||||
export function isDangerousHostEnvVarName(key: string): boolean {
|
||||
const upper = key.toUpperCase();
|
||||
if (HOST_DANGEROUS_ENV_KEYS.has(upper)) {
|
||||
return true;
|
||||
}
|
||||
return HOST_DANGEROUS_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function sanitizeHostExecEnv(params?: {
|
||||
baseEnv?: Record<string, string | undefined>;
|
||||
overrides?: Record<string, string> | null;
|
||||
blockPathOverrides?: boolean;
|
||||
}): Record<string, string> {
|
||||
const baseEnv = params?.baseEnv ?? process.env;
|
||||
const overrides = params?.overrides ?? undefined;
|
||||
const blockPathOverrides = params?.blockPathOverrides ?? true;
|
||||
|
||||
const merged: Record<string, string> = {};
|
||||
for (const [rawKey, value] of Object.entries(baseEnv)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const key = rawKey.trim();
|
||||
if (!key || isDangerousHostEnvVarName(key)) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
if (!overrides) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const upper = key.toUpperCase();
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
|
||||
// request-scoped PATH overrides from agents/gateways.
|
||||
if (blockPathOverrides && upper === "PATH") {
|
||||
continue;
|
||||
}
|
||||
if (isDangerousHostEnvVarName(upper)) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe("node-host sanitizeEnv", () => {
|
||||
const prev = process.env.PATH;
|
||||
process.env.PATH = "/usr/bin";
|
||||
try {
|
||||
const env = sanitizeEnv({ PATH: "/tmp/evil:/usr/bin" }) ?? {};
|
||||
const env = sanitizeEnv({ PATH: "/tmp/evil:/usr/bin" });
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
@@ -21,18 +21,21 @@ describe("node-host sanitizeEnv", () => {
|
||||
it("blocks dangerous env keys/prefixes", () => {
|
||||
const prevPythonPath = process.env.PYTHONPATH;
|
||||
const prevLdPreload = process.env.LD_PRELOAD;
|
||||
const prevBashEnv = process.env.BASH_ENV;
|
||||
try {
|
||||
delete process.env.PYTHONPATH;
|
||||
delete process.env.LD_PRELOAD;
|
||||
const env =
|
||||
sanitizeEnv({
|
||||
PYTHONPATH: "/tmp/pwn",
|
||||
LD_PRELOAD: "/tmp/pwn.so",
|
||||
FOO: "bar",
|
||||
}) ?? {};
|
||||
delete process.env.BASH_ENV;
|
||||
const env = sanitizeEnv({
|
||||
PYTHONPATH: "/tmp/pwn",
|
||||
LD_PRELOAD: "/tmp/pwn.so",
|
||||
BASH_ENV: "/tmp/pwn.sh",
|
||||
FOO: "bar",
|
||||
});
|
||||
expect(env.FOO).toBe("bar");
|
||||
expect(env.PYTHONPATH).toBeUndefined();
|
||||
expect(env.LD_PRELOAD).toBeUndefined();
|
||||
expect(env.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (prevPythonPath === undefined) {
|
||||
delete process.env.PYTHONPATH;
|
||||
@@ -44,6 +47,34 @@ describe("node-host sanitizeEnv", () => {
|
||||
} else {
|
||||
process.env.LD_PRELOAD = prevLdPreload;
|
||||
}
|
||||
if (prevBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = prevBashEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("drops dangerous inherited env keys even without overrides", () => {
|
||||
const prevPath = process.env.PATH;
|
||||
const prevBashEnv = process.env.BASH_ENV;
|
||||
try {
|
||||
process.env.PATH = "/usr/bin:/bin";
|
||||
process.env.BASH_ENV = "/tmp/pwn.sh";
|
||||
const env = sanitizeEnv(undefined);
|
||||
expect(env.PATH).toBe("/usr/bin:/bin");
|
||||
expect(env.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (prevPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = prevPath;
|
||||
}
|
||||
if (prevBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = prevBashEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ExecHostRunResult,
|
||||
} from "../infra/exec-host.js";
|
||||
import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
||||
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
|
||||
import { validateSystemRunCommandConsistency } from "../infra/system-run-command.js";
|
||||
import { runBrowserProxyCommand } from "./invoke-browser.js";
|
||||
|
||||
@@ -43,17 +44,6 @@ const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase
|
||||
const execHostFallbackAllowed =
|
||||
process.env.OPENCLAW_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
||||
|
||||
const blockedEnvKeys = new Set([
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYOPT",
|
||||
]);
|
||||
|
||||
const blockedEnvPrefixes = ["DYLD_", "LD_"];
|
||||
|
||||
type SystemRunParams = {
|
||||
command: string[];
|
||||
rawCommand?: string | null;
|
||||
@@ -136,33 +126,8 @@ function resolveExecAsk(value?: string): ExecAsk {
|
||||
return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss";
|
||||
}
|
||||
|
||||
export function sanitizeEnv(
|
||||
overrides?: Record<string, string> | null,
|
||||
): Record<string, string> | undefined {
|
||||
if (!overrides) {
|
||||
return undefined;
|
||||
}
|
||||
const merged = { ...process.env } as Record<string, string>;
|
||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const upper = key.toUpperCase();
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
|
||||
// request-scoped PATH overrides from agents/gateways.
|
||||
if (upper === "PATH") {
|
||||
continue;
|
||||
}
|
||||
if (blockedEnvKeys.has(upper)) {
|
||||
continue;
|
||||
}
|
||||
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
export function sanitizeEnv(overrides?: Record<string, string> | null): Record<string, string> {
|
||||
return sanitizeHostExecEnv({ overrides, blockPathOverrides: true });
|
||||
}
|
||||
|
||||
function truncateOutput(raw: string, maxChars: number): { text: string; truncated: boolean } {
|
||||
|
||||
Reference in New Issue
Block a user