Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
23d0555e79 fix: harden host exec env validation (#4896) (thanks @HassanFleyah) 2026-02-01 15:35:48 -08:00
HassanFleyah
979f4d8b25 fix(security): enforce strict environment variable validation in exec tool 2026-02-01 15:27:09 -08:00
4 changed files with 90 additions and 12 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Browser: secure Chrome extension relay CDP sessions.
- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
- Security: block LD_/DYLD_ env overrides for host exec. (#4896) Thanks @HassanFleyah.
- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.
## 2026.1.30

View File

@@ -36,6 +36,8 @@ Notes:
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
prevent binary hijacking or injected code.
- Important: sandboxing is **off by default**. If sandboxing is off, `host=sandbox` runs directly on
the gateway host (no container) and **does not require approvals**. To require approvals, run with
`host=gateway` and configure exec approvals (or enable sandboxing).
@@ -65,16 +67,16 @@ Example:
### PATH handling
- `host=gateway`: merges your login-shell `PATH` into the exec environment (unless the exec call
already sets `env.PATH`). The daemon itself still runs with a minimal `PATH`:
- `host=gateway`: merges your login-shell `PATH` into the exec environment. `env.PATH` overrides are
rejected for host execution. The daemon itself still runs with a minimal `PATH`:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
OpenClaw prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation);
`tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
- `host=node`: only non-blocked env overrides you pass are sent to the node. `env.PATH` overrides are
rejected for host execution. Headless node hosts accept `PATH` only when it prepends the node host
PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
Per-agent node binding (use the agent list index in config):

View File

@@ -86,7 +86,7 @@ describe("exec PATH login shell merge", () => {
expect(shellPathMock).toHaveBeenCalledTimes(1);
});
it("skips login-shell PATH when env.PATH is provided", async () => {
it("throws security violation when env.PATH is provided", async () => {
if (isWin) {
return;
}
@@ -98,13 +98,28 @@ describe("exec PATH login shell merge", () => {
shellPathMock.mockClear();
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call1", {
command: "echo $PATH",
env: { PATH: "/explicit/bin" },
});
const entries = normalizePathEntries(result.content.find((c) => c.type === "text")?.text);
expect(entries).toEqual(["/explicit/bin"]);
await expect(
tool.execute("call1", {
command: "echo $PATH",
env: { PATH: "/explicit/bin" },
}),
).rejects.toThrow(/Security Violation: Custom 'PATH' variable is forbidden/);
expect(shellPathMock).not.toHaveBeenCalled();
});
});
describe("exec host env validation", () => {
it("blocks LD_/DYLD_ env vars on host execution", async () => {
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call1", {
command: "echo ok",
env: { LD_DEBUG: "1" },
}),
).rejects.toThrow(/Security Violation: Environment variable 'LD_DEBUG' is forbidden/);
});
});

View File

@@ -56,6 +56,55 @@ import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-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.
function validateHostEnv(env: Record<string, string>): void {
for (const key of Object.keys(env)) {
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)) {
throw new Error(
`Security Violation: Environment variable '${key}' is forbidden during host execution.`,
);
}
// 2. Strictly block PATH modification on host
// Allowing custom PATH on the gateway/node can lead to binary hijacking.
if (upperKey === "PATH") {
throw new Error(
"Security Violation: Custom 'PATH' variable is forbidden during host execution.",
);
}
}
}
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
200_000,
@@ -916,7 +965,15 @@ export function createExecTool(
}
const baseEnv = coerceEnv(process.env);
// Logic: Sandbox gets raw env. Host (gateway/node) must pass validation.
// We validate BEFORE merging to prevent any dangerous vars from entering the stream.
if (host !== "sandbox" && params.env) {
validateHostEnv(params.env);
}
const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
const env = sandbox
? buildSandboxEnv({
defaultPath: DEFAULT_PATH,
@@ -925,6 +982,7 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
if (!sandbox && host === "gateway" && !params.env?.PATH) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
@@ -976,7 +1034,9 @@ export function createExecTool(
);
}
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
const nodeEnv = params.env ? { ...params.env } : undefined;
if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
}