From 4d912e04519b4bd53b248437c53748cdebce9a41 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 31 Mar 2026 21:25:36 +0900 Subject: [PATCH] fix(exec): block proxy-style env overrides (#58202) * fix(exec): block proxy-style env overrides * fix(exec): keep trusted host proxy env inherited * fix(exec): block git tls override env vars * fix(skills): block dangerous env override keys --- CHANGELOG.md | 1 + .../HostEnvSecurityPolicy.generated.swift | 19 +++++ src/agents/bash-tools.exec.path.test.ts | 16 ++++ src/agents/skills.test.ts | 44 ++++++++++ src/agents/skills/env-overrides.ts | 9 ++- src/infra/host-env-security-policy.json | 19 +++++ src/infra/host-env-security.test.ts | 81 +++++++++++++++++++ 7 files changed, 187 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0680e86336..bf34fb27eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai - Agents/Kimi: preserve already-valid Anthropic-compatible tool call argument objects while still clearing cached repairs when later trailing junk exceeds the repair allowance. (#54491) Thanks @yuanaichi. - Docker/setup: force BuildKit for local image builds (including sandbox image builds) so `./docker-setup.sh` no longer fails on `RUN --mount=...` when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china. - Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as `Not set`. (#56637) Thanks @dxsx84. +- Exec/env: block proxy, TLS, and Docker endpoint env overrides in host execution so request-scoped commands cannot silently reroute outbound traffic or trust attacker-supplied certificate settings. Thanks @AntAISecurityLab. - Control UI/slash commands: make `/steer` and `/redirect` work from the chat command palette with visible pending state for active-run `/steer`, correct redirected-run tracking, and a single canonical `/steer` entry in the command menu. (#54625) Thanks @fuller-stack-dev. - Exec/approvals: keep `awk` and `sed` family binaries out of the low-risk `safeBins` fast path, and stop doctor profile scaffolding from treating them like ordinary custom filters. Thanks @vincentkoc. - Exec/runtime: default implicit exec to `host=auto`, resolve that target to sandbox only when a sandbox runtime exists, keep explicit `host=sandbox` fail-closed without sandbox, and show `/exec` effective host state in runtime status/docs. diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 2acc16b601d..19d381e0a8b 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -22,6 +22,9 @@ enum HostEnvSecurityPolicy { "GIT_EXEC_PATH", "GIT_SEQUENCE_EDITOR", "GIT_TEMPLATE_DIR", + "GIT_SSL_NO_VERIFY", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", "CC", "CXX", "CARGO_BUILD_RUSTC", @@ -54,6 +57,9 @@ enum HostEnvSecurityPolicy { "GIT_SSH", "GIT_PROXY_COMMAND", "GIT_ASKPASS", + "GIT_SSL_NO_VERIFY", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", "SSH_ASKPASS", "LESSOPEN", "LESSCLOSE", @@ -82,6 +88,19 @@ enum HostEnvSecurityPolicy { "PHP_INI_SCAN_DIR", "DENO_DIR", "BUN_CONFIG_REGISTRY", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", "PIP_INDEX_URL", "PIP_PYPI_URL", "PIP_EXTRA_INDEX_URL", diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index e4299c3136c..153fe304a52 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -228,6 +228,22 @@ describe("exec host env validation", () => { ).rejects.toThrow(/Security Violation: Environment variable 'LD_DEBUG' is forbidden/); }); + it("blocks proxy and TLS override env vars on host execution", async () => { + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + + await expect( + tool.execute("call1", { + command: "echo ok", + env: { + HTTPS_PROXY: "http://proxy.example.test:8080", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + }, + }), + ).rejects.toThrow( + /Security Violation: blocked override keys: HTTPS_PROXY, NODE_TLS_REJECT_UNAUTHORIZED\./, + ); + }); + it("strips dangerous inherited env vars from host execution", async () => { if (isWin) { return; diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 1a75d1e3841..5190a78be2f 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -511,6 +511,50 @@ describe("applySkillEnvOverrides", () => { }); }); + it("blocks override-only host env overrides in skill config", async () => { + const workspaceDir = await makeWorkspace(); + const skillDir = path.join(workspaceDir, "skills", "override-env-skill"); + await writeSkill({ + dir: skillDir, + name: "override-env-skill", + description: "Needs env", + metadata: + '{"openclaw":{"requires":{"env":["HTTPS_PROXY","NODE_TLS_REJECT_UNAUTHORIZED","DOCKER_HOST"]}}}', + }); + + const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir)); + + withClearedEnv(["HTTPS_PROXY", "NODE_TLS_REJECT_UNAUTHORIZED", "DOCKER_HOST"], () => { + const restore = applySkillEnvOverrides({ + skills: entries, + config: { + skills: { + entries: { + "override-env-skill": { + env: { + HTTPS_PROXY: "http://proxy.example.test:8080", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + DOCKER_HOST: "tcp://docker.example.test:2376", + }, + }, + }, + }, + }, + }); + + try { + expect(process.env.HTTPS_PROXY).toBeUndefined(); + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); + expect(process.env.DOCKER_HOST).toBeUndefined(); + } finally { + restore(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); + expect(process.env.DOCKER_HOST).toBeUndefined(); + } + }); + }); + it("allows required env overrides from snapshots", async () => { const workspaceDir = await makeWorkspace(); const skillDir = path.join(workspaceDir, "skills", "snapshot-env-skill"); diff --git a/src/agents/skills/env-overrides.ts b/src/agents/skills/env-overrides.ts index 2fe8bd5cf6d..1631526d13a 100644 --- a/src/agents/skills/env-overrides.ts +++ b/src/agents/skills/env-overrides.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig } from "../../config/config.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; -import { isDangerousHostEnvVarName } from "../../infra/host-env-security.js"; +import { + isDangerousHostEnvOverrideVarName, + isDangerousHostEnvVarName, +} from "../../infra/host-env-security.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { sanitizeEnvVars, validateEnvVarValue } from "../sandbox/sanitize-env-vars.js"; import { resolveSkillConfig } from "./config.js"; @@ -86,7 +89,9 @@ function matchesAnyPattern(value: string, patterns: readonly RegExp[]): boolean function isAlwaysBlockedSkillEnvKey(key: string): boolean { return ( - isDangerousHostEnvVarName(key) || matchesAnyPattern(key, SKILL_ALWAYS_BLOCKED_ENV_PATTERNS) + isDangerousHostEnvVarName(key) || + isDangerousHostEnvOverrideVarName(key) || + matchesAnyPattern(key, SKILL_ALWAYS_BLOCKED_ENV_PATTERNS) ); } diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 6e0dd215f25..d867ab67b06 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -16,6 +16,9 @@ "GIT_EXEC_PATH", "GIT_SEQUENCE_EDITOR", "GIT_TEMPLATE_DIR", + "GIT_SSL_NO_VERIFY", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", "CC", "CXX", "CARGO_BUILD_RUSTC", @@ -47,6 +50,9 @@ "GIT_SSH", "GIT_PROXY_COMMAND", "GIT_ASKPASS", + "GIT_SSL_NO_VERIFY", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", "SSH_ASKPASS", "LESSOPEN", "LESSCLOSE", @@ -75,6 +81,19 @@ "PHP_INI_SCAN_DIR", "DENO_DIR", "BUN_CONFIG_REGISTRY", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", "PIP_INDEX_URL", "PIP_PYPI_URL", "PIP_EXTRA_INDEX_URL", diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index bb063dcb1b0..545911befea 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -174,6 +174,21 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("gradle_opts")).toBe(true); expect(isDangerousHostEnvVarName("ANT_OPTS")).toBe(true); expect(isDangerousHostEnvVarName("ant_opts")).toBe(true); + expect(isDangerousHostEnvVarName("HTTPS_PROXY")).toBe(false); + expect(isDangerousHostEnvVarName("https_proxy")).toBe(false); + expect(isDangerousHostEnvVarName("HTTP_PROXY")).toBe(false); + expect(isDangerousHostEnvVarName("http_proxy")).toBe(false); + expect(isDangerousHostEnvVarName("ALL_PROXY")).toBe(false); + expect(isDangerousHostEnvVarName("no_proxy")).toBe(false); + expect(isDangerousHostEnvVarName("NODE_TLS_REJECT_UNAUTHORIZED")).toBe(false); + expect(isDangerousHostEnvVarName("node_extra_ca_certs")).toBe(false); + expect(isDangerousHostEnvVarName("SSL_CERT_FILE")).toBe(false); + expect(isDangerousHostEnvVarName("SSL_CERT_DIR")).toBe(false); + expect(isDangerousHostEnvVarName("requests_ca_bundle")).toBe(false); + expect(isDangerousHostEnvVarName("CURL_CA_BUNDLE")).toBe(false); + expect(isDangerousHostEnvVarName("DOCKER_HOST")).toBe(false); + expect(isDangerousHostEnvVarName("docker_cert_path")).toBe(false); + expect(isDangerousHostEnvVarName("DOCKER_TLS_VERIFY")).toBe(false); expect(isDangerousHostEnvVarName("AWS_CONFIG_FILE")).toBe(false); expect(isDangerousHostEnvVarName("aws_config_file")).toBe(false); expect(isDangerousHostEnvVarName("PATH")).toBe(false); @@ -257,6 +272,9 @@ describe("sanitizeHostExecEnv", () => { SSL_CERT_DIR: "/tmp/evil-cert-dir", REQUESTS_CA_BUNDLE: "/tmp/evil-requests-ca.pem", CURL_CA_BUNDLE: "/tmp/evil-curl-ca.pem", + GIT_SSL_NO_VERIFY: "1", + GIT_SSL_CAINFO: "/tmp/evil-git-ca.pem", + GIT_SSL_CAPATH: "/tmp/evil-git-ca-dir", GOPROXY: "https://example.invalid/proxy", GONOSUMCHECK: "example.invalid/*", GONOSUMDB: "example.invalid/*", @@ -338,6 +356,54 @@ describe("sanitizeHostExecEnv", () => { expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir"); }); + it("keeps trusted inherited proxy, TLS, and Docker env while blocking overrides", () => { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + HTTP_PROXY: "http://trusted-proxy.example.test:8080", + HTTPS_PROXY: "http://trusted-proxy.example.test:8443", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + SSL_CERT_DIR: "/etc/ssl/certs", + CURL_CA_BUNDLE: "/etc/ssl/cert.pem", + DOCKER_TLS_VERIFY: "1", + }, + overrides: { + HTTP_PROXY: "http://evil-proxy.example.test:8080", + NODE_TLS_REJECT_UNAUTHORIZED: "1", + DOCKER_TLS_VERIFY: "0", + }, + }); + + expect(env).toEqual({ + OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, + PATH: "/usr/bin:/bin", + HTTP_PROXY: "http://trusted-proxy.example.test:8080", + HTTPS_PROXY: "http://trusted-proxy.example.test:8443", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + SSL_CERT_DIR: "/etc/ssl/certs", + CURL_CA_BUNDLE: "/etc/ssl/cert.pem", + DOCKER_TLS_VERIFY: "1", + }); + }); + + it("blocks proxy, TLS, and Docker override values explicitly", () => { + expect(isDangerousHostEnvOverrideVarName("HTTPS_PROXY")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("https_proxy")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("HTTP_PROXY")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("http_proxy")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("ALL_PROXY")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("no_proxy")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("NODE_TLS_REJECT_UNAUTHORIZED")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("node_extra_ca_certs")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("SSL_CERT_FILE")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("SSL_CERT_DIR")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("requests_ca_bundle")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("CURL_CA_BUNDLE")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("DOCKER_HOST")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("docker_cert_path")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("DOCKER_TLS_VERIFY")).toBe(true); + }); + it("drops dangerous inherited shell trace keys", () => { const env = sanitizeHostExecEnv({ baseEnv: { @@ -503,6 +569,11 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { GOPATH: "/tmp/evil-go", PYTHONUSERBASE: "/tmp/evil-python-userbase", VIRTUAL_ENV: "/tmp/evil-venv", + HTTPS_PROXY: "http://proxy.example.test:8080", + GIT_SSL_NO_VERIFY: "1", + GIT_SSL_CAINFO: "/tmp/evil-git-ca.pem", + GIT_SSL_CAPATH: "/tmp/evil-git-capath", + NODE_TLS_REJECT_UNAUTHORIZED: "0", SAFE_KEY: "ok", "BAD-KEY": "bad", }, @@ -520,6 +591,9 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { "DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS_VERIFY", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", "GOENV", "GONOPROXY", "GONOSUMCHECK", @@ -527,8 +601,10 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { "GOPATH", "GOPRIVATE", "GOPROXY", + "HTTPS_PROXY", "LIBRARY_PATH", "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", "OBJC_INCLUDE_PATH", "PATH", "PIP_CONFIG_FILE", @@ -563,6 +639,9 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { expect(result.env.UV_INDEX_URL).toBeUndefined(); expect(result.env.UV_DEFAULT_INDEX).toBeUndefined(); expect(result.env.UV_EXTRA_INDEX_URL).toBeUndefined(); + expect(result.env.GIT_SSL_NO_VERIFY).toBeUndefined(); + expect(result.env.GIT_SSL_CAINFO).toBeUndefined(); + expect(result.env.GIT_SSL_CAPATH).toBeUndefined(); expect(result.env.DOCKER_HOST).toBeUndefined(); expect(result.env.DOCKER_TLS_VERIFY).toBeUndefined(); expect(result.env.DOCKER_CERT_PATH).toBeUndefined(); @@ -584,6 +663,8 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { expect(result.env.GOPRIVATE).toBeUndefined(); expect(result.env.GOENV).toBeUndefined(); expect(result.env.GOPATH).toBeUndefined(); + expect(result.env.HTTPS_PROXY).toBeUndefined(); + expect(result.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); expect(result.env.PYTHONUSERBASE).toBeUndefined(); expect(result.env.VIRTUAL_ENV).toBeUndefined(); });