fix(security): block startup-file env injection across host execution paths

This commit is contained in:
Peter Steinberger
2026-02-21 11:43:53 +01:00
parent 6b2f2811dc
commit 2cdbadee1f
13 changed files with 318 additions and 147 deletions

View File

@@ -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.

View File

@@ -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)
}
}

View 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
}
}

View File

@@ -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 {

View File

@@ -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.`,
);

View File

@@ -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");

View File

@@ -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: {

View File

@@ -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 () => {

View File

@@ -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;
}

View 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");
});
});

View 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;
}

View File

@@ -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;
}
}
});
});

View File

@@ -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 } {