diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index c431028abfc6..c8e43ba38ee4 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -24,6 +24,15 @@ enum HostEnvSanitizer { "NO_COLOR", "FORCE_COLOR", ] + private static let gitAllowProtocolKey = "GIT_ALLOW_PROTOCOL" + private static let gitProtocolFromUserKey = "GIT_PROTOCOL_FROM_USER" + private static let gitProtocolFromUserDisabledValue = "0" + private static let gitDefaultAlwaysAllowedProtocols: Set = [ + "git", + "http", + "https", + "ssh", + ] private static func isBlocked(_ upperKey: String) -> Bool { if self.blockedKeys.contains(upperKey) { return true } @@ -82,6 +91,25 @@ enum HostEnvSanitizer { Array(Set(values)).sorted() } + private static func isPermissiveGitProtocolFromUserValue(_ value: String) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "true" || normalized == "yes" || normalized == "on" { + return true + } + let isInteger = normalized.range(of: #"^[+-]?[0-9]+$"#, options: .regularExpression) != nil + let isZero = normalized.range(of: #"^[+-]?0+$"#, options: .regularExpression) != nil + return isInteger && !isZero + } + + private static func sanitizeInheritedGitAllowProtocolValue(_ value: String) -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty { return "" } + let safeProtocols = normalized + .split(separator: ":", omittingEmptySubsequences: false) + .filter { self.gitDefaultAlwaysAllowedProtocols.contains(String($0)) } + return safeProtocols.joined(separator: ":") + } + static func inspectOverrides( overrides: [String: String]?, blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics @@ -120,6 +148,22 @@ enum HostEnvSanitizer { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } let upper = key.uppercased() + // Preserve inherited Git allowlists without widening malformed or unsafe entries by + // deletion. Protocols outside Git's safe default set are removed instead. + if upper == self.gitAllowProtocolKey { + merged[key] = self.sanitizeInheritedGitAllowProtocolValue(value) + continue + } + // Preserve non-permissive Git boolean values. Permissive values must become explicit + // `0` because Git's unset default still permits protocols with policy `user`. + if upper == self.gitProtocolFromUserKey { + if !self.isPermissiveGitProtocolFromUserValue(value) { + merged[key] = value + } else { + merged[key] = self.gitProtocolFromUserDisabledValue + } + continue + } if self.isBlockedInherited(upper) { continue } merged[key] = value } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 747687866dc8..1750022cfd10 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -78,6 +78,7 @@ enum HostEnvSecurityPolicy { "GH_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN", + "GIT_ALLOW_PROTOCOL", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_ASKPASS", "GIT_COMMON_DIR", @@ -89,6 +90,7 @@ enum HostEnvSecurityPolicy { "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", + "GIT_PROTOCOL_FROM_USER", "GIT_PROXY_COMMAND", "GIT_SEQUENCE_EDITOR", "GIT_SSH", @@ -239,6 +241,7 @@ enum HostEnvSecurityPolicy { "EXINIT", "FPATH", "GCONV_PATH", + "GIT_ALLOW_PROTOCOL", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_DIR", @@ -249,6 +252,7 @@ enum HostEnvSecurityPolicy { "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", + "GIT_PROTOCOL_FROM_USER", "GIT_SEQUENCE_EDITOR", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index 15c909626b1e..50ea1d0f92d9 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -34,6 +34,7 @@ let formatExecFailureReason: typeof import("./bash-tools.exec-runtime.js").forma let renderExecUpdateText: typeof import("./bash-tools.exec-runtime.js").renderExecUpdateText; let resolveExecTarget: typeof import("./bash-tools.exec-runtime.js").resolveExecTarget; let runExecProcess: typeof import("./bash-tools.exec-runtime.js").runExecProcess; +let sanitizeHostBaseEnv: typeof import("./bash-tools.exec-runtime.js").sanitizeHostBaseEnv; beforeAll(async () => { ({ markBackgrounded } = await import("./bash-process-registry.js")); @@ -45,6 +46,7 @@ beforeAll(async () => { renderExecUpdateText, resolveExecTarget, runExecProcess, + sanitizeHostBaseEnv, } = await import("./bash-tools.exec-runtime.js")); }); @@ -113,6 +115,33 @@ describe("detectCursorKeyMode", () => { }); }); +describe("sanitizeHostBaseEnv", () => { + it("uses value-aware Git protocol inherited env sanitization", () => { + expect( + sanitizeHostBaseEnv({ + PATH: "/usr/bin:/bin", + GIT_ALLOW_PROTOCOL: "https:ext:ssh", + GIT_PROTOCOL_FROM_USER: "1", + GIT_SSH_COMMAND: "touch /tmp/pwned", + SAFE: "ok", + }), + ).toEqual({ + PATH: "/usr/bin:/bin", + GIT_ALLOW_PROTOCOL: "https:ssh", + GIT_PROTOCOL_FROM_USER: "0", + SAFE: "ok", + }); + + expect( + sanitizeHostBaseEnv({ + GIT_PROTOCOL_FROM_USER: "false", + }), + ).toEqual({ + GIT_PROTOCOL_FROM_USER: "false", + }); + }); +}); + describe("resolveExecTarget", () => { it("keeps implicit auto on sandbox when a sandbox runtime is available", () => { expectExecTarget( diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index c85b07ffbbde..1d2f55a56953 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -19,7 +19,10 @@ import { type ExecTarget, } from "../infra/exec-approvals.js"; import { requestHeartbeat } from "../infra/heartbeat-wake.js"; -import { isDangerousHostInheritedEnvVarName } from "../infra/host-env-security.js"; +import { + isDangerousHostInheritedEnvVarName, + sanitizeHostInheritedEnvEntry, +} from "../infra/host-env-security.js"; import { findPathKey, mergePathPrepend, removePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { isSubagentSessionKey } from "../sessions/session-key-utils.js"; @@ -93,15 +96,12 @@ export function detectCursorKeyMode(raw: string): "application" | "normal" | nul export function sanitizeHostBaseEnv(env: Record): Record { const sanitized: Record = {}; for (const [key, value] of Object.entries(env)) { - const upperKey = key.toUpperCase(); - if (upperKey === "PATH") { - sanitized[key] = value; + const sanitizedEntry = sanitizeHostInheritedEnvEntry(key, value); + if (!sanitizedEntry) { continue; } - if (isDangerousHostInheritedEnvVarName(upperKey)) { - continue; - } - sanitized[key] = value; + const [sanitizedKey, sanitizedValue] = sanitizedEntry; + sanitized[sanitizedKey] = sanitizedValue; } return sanitized; } diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 9454e69966f8..3047029a98fd 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -17,6 +17,7 @@ "ENV", "KSH_ENV", "BROWSER", + "GIT_ALLOW_PROTOCOL", "GIT_EDITOR", "GIT_EXTERNAL_DIFF", "GIT_DIR", @@ -27,6 +28,7 @@ "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", + "GIT_PROTOCOL_FROM_USER", "GIT_SEQUENCE_EDITOR", "GIT_TEMPLATE_DIR", "GIT_SSL_NO_VERIFY", diff --git a/src/infra/host-env-security.reported-baseline.json b/src/infra/host-env-security.reported-baseline.json index fda9e6658f14..18ef19e38507 100644 --- a/src/infra/host-env-security.reported-baseline.json +++ b/src/infra/host-env-security.reported-baseline.json @@ -31,6 +31,7 @@ "EXINIT", "FPATH", "GCONV_PATH", + "GIT_ALLOW_PROTOCOL", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_DIR", @@ -41,6 +42,7 @@ "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", + "GIT_PROTOCOL_FROM_USER", "GIT_SEQUENCE_EDITOR", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", @@ -257,5 +259,5 @@ "YARN_RC_FILENAME", "ZDOTDIR" ], - "expectedTotalReportedEntries": 252 + "expectedTotalReportedEntries": 254 } diff --git a/src/infra/host-env-security.reported-baseline.test.ts b/src/infra/host-env-security.reported-baseline.test.ts index decf8a1b4095..ac71860f8078 100644 --- a/src/infra/host-env-security.reported-baseline.test.ts +++ b/src/infra/host-env-security.reported-baseline.test.ts @@ -97,7 +97,7 @@ describe("host env reported baseline coverage", () => { baseline.reportedDangerousEverywhereKeys.length + baseline.reportedDangerousOverrideOnlyKeys.length, ).toBe(baseline.expectedTotalReportedEntries); - expect(baseline.expectedTotalReportedEntries).toBe(252); + expect(baseline.expectedTotalReportedEntries).toBe(254); expect(sortUniqueUpper(baseline.reportedDangerousEverywhereKeys)).toEqual( baseline.reportedDangerousEverywhereKeys, ); @@ -119,6 +119,14 @@ describe("host env reported baseline coverage", () => { for (const key of baseline.reportedDangerousEverywhereKeys) { expect(isDangerousHostEnvVarName(key)).toBe(true); expect(isDangerousHostInheritedEnvVarName(key)).toBe(true); + if (key === "GIT_ALLOW_PROTOCOL") { + expect(inheritedSanitized[key]).toBe(""); + continue; + } + if (key === "GIT_PROTOCOL_FROM_USER") { + expect(inheritedSanitized[key]).toBe(`${key.toLowerCase()}-from-inherited`); + continue; + } expect(inheritedSanitized[key]).toBeUndefined(); } diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index 1c79de84da23..40bfa3a0d164 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -47,6 +47,10 @@ function clearMarker(marker: string) { } } +function envRecord(entries: ReadonlyArray): Record { + return Object.fromEntries(entries); +} + async function runGitLsRemote(gitPath: string, target: string, env: NodeJS.ProcessEnv) { await new Promise((resolve) => { const child = spawn(gitPath, ["ls-remote", target], { env, stdio: "ignore" }); @@ -74,6 +78,25 @@ async function runGitCommand( }); } +async function runGitCommandExitCode( + gitPath: string, + args: string[], + options?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + }, +): Promise { + return await new Promise((resolve) => { + const child = spawn(gitPath, args, { + cwd: options?.cwd, + env: options?.env, + stdio: "ignore", + }); + child.once("error", () => resolve(null)); + child.once("close", (code) => resolve(code)); + }); +} + async function runGitClone( gitPath: string, source: string, @@ -133,6 +156,8 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("BROWSER")).toBe(true); expect(isDangerousHostEnvVarName("browser")).toBe(true); expect(isDangerousHostEnvVarName("SHELL")).toBe(true); + expect(isDangerousHostEnvVarName("GIT_ALLOW_PROTOCOL")).toBe(true); + expect(isDangerousHostEnvVarName("git_protocol_from_user")).toBe(true); expect(isDangerousHostEnvVarName("GIT_EDITOR")).toBe(true); expect(isDangerousHostEnvVarName("git_editor")).toBe(true); expect(isDangerousHostEnvVarName("GIT_EXTERNAL_DIFF")).toBe(true); @@ -316,6 +341,7 @@ describe("sanitizeHostExecEnv", () => { PATH: "/usr/bin:/bin", BASH_ENV: "/tmp/pwn.sh", BROWSER: "/tmp/pwn-browser", + GIT_ALLOW_PROTOCOL: "ext", GIT_EDITOR: "/tmp/pwn-editor", GIT_EXTERNAL_DIFF: "/tmp/pwn.sh", GIT_DIR: "/tmp/evil-git-dir", @@ -326,6 +352,7 @@ describe("sanitizeHostExecEnv", () => { GIT_OBJECT_DIRECTORY: "/tmp/evil-git-objects", GIT_ALTERNATE_OBJECT_DIRECTORIES: "/tmp/evil-git-alt-objects", GIT_NAMESPACE: "evil-namespace", + GIT_PROTOCOL_FROM_USER: "1", GIT_SEQUENCE_EDITOR: "/tmp/pwn-sequence-editor", HGRCPATH: "/tmp/evil-hgrc", CARGO_BUILD_RUSTC_WRAPPER: "/tmp/evil-rustc-wrapper", @@ -382,6 +409,8 @@ describe("sanitizeHostExecEnv", () => { SSL_CERT_DIR: "/tmp/evil-cert-dir", DOCKER_CONTEXT: "trusted-remote", DOCKER_HOST: "tcp://docker.example.test:2376", + GIT_ALLOW_PROTOCOL: "", + GIT_PROTOCOL_FROM_USER: "0", RUSTUP_DIST_ROOT: "https://mirror.example.test/deprecated-dist", RUSTUP_DIST_SERVER: "https://mirror.example.test", RUSTUP_HOME: "/tmp/rustup-home", @@ -391,115 +420,187 @@ describe("sanitizeHostExecEnv", () => { }); }); + it("preserves inherited non-permissive GIT_PROTOCOL_FROM_USER values", () => { + const restrictiveValues = ["", "0", "00", "+0", "-0", "false", "False", "no", "off", "maybe"]; + + for (const value of restrictiveValues) { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + GIT_PROTOCOL_FROM_USER: value, + }, + }); + + expect(env.GIT_PROTOCOL_FROM_USER).toBe(value); + } + }); + + it("preserves inherited safe GIT_ALLOW_PROTOCOL allowlist values", () => { + const safeValues = ["", "git", "https:ssh", "git:http:https:ssh"]; + + for (const value of safeValues) { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + GIT_ALLOW_PROTOCOL: value, + }, + }); + + expect(env.GIT_ALLOW_PROTOCOL).toBe(value); + } + }); + + it("filters inherited GIT_ALLOW_PROTOCOL allowlists to Git safe defaults", () => { + const cases = [ + ["ext", ""], + ["https:ext", "https"], + ["file", ""], + ["https:file", "https"], + ["hg", ""], + ["https::ssh", "https:ssh"], + ] as const; + + for (const [value, expected] of cases) { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + GIT_ALLOW_PROTOCOL: value, + }, + }); + + expect(env.GIT_ALLOW_PROTOCOL).toBe(expected); + } + }); + + it("forces inherited permissive GIT_PROTOCOL_FROM_USER values to the Git disabled value", () => { + const permissiveValues = ["1", "01", "+1", "-1", "2", "true", "yes", "on"]; + + for (const value of permissiveValues) { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + GIT_PROTOCOL_FROM_USER: value, + }, + }); + + expect(env.GIT_PROTOCOL_FROM_USER).toBe("0"); + } + }); + it("blocks PATH and dangerous override values", () => { - // Regression fixture intentionally feeds blocked override pivots into the sanitizer. - const baseEnv = { - PATH: "/usr/bin:/bin", - HOME: "/tmp/trusted-home", - ZDOTDIR: "/tmp/trusted-zdotdir", - CARGO_REGISTRIES_CRATES_IO_INDEX: "https://trusted.example/crates.io-index", - YARN_RC_FILENAME: ".trusted-yarnrc.yml", - }; - const overrides = { - PATH: "/tmp/evil", - HOME: "/tmp/evil-home", - ZDOTDIR: "/tmp/evil-zdotdir", - BASH_ENV: "/tmp/pwn.sh", - BROWSER: "/tmp/browser", - CC: "/tmp/evil-cc", - CXX: "/tmp/evil-cxx", - CARGO_BUILD_RUSTC: "/tmp/evil-rustc", - CARGO_BUILD_RUSTC_WRAPPER: "/tmp/evil-rustc-wrapper", - CMAKE_C_COMPILER: "/tmp/evil-c-compiler", - CMAKE_CXX_COMPILER: "/tmp/evil-cxx-compiler", - RUSTC_WRAPPER: "/tmp/evil-rustc-wrapper", - HGRCPATH: "/tmp/evil-hgrc", - GIT_SSH_COMMAND: "touch /tmp/pwned", - GIT_EDITOR: "/tmp/git-editor", - GIT_DIR: "/tmp/evil-git-dir", - GIT_WORK_TREE: "/tmp/evil-work-tree", - GIT_COMMON_DIR: "/tmp/evil-common-dir", - GIT_EXEC_PATH: "/tmp/git-exec-path", - GIT_INDEX_FILE: "/tmp/evil-git-index", - GIT_OBJECT_DIRECTORY: "/tmp/evil-git-objects", - GIT_ALTERNATE_OBJECT_DIRECTORIES: "/tmp/evil-git-alt-objects", - GIT_NAMESPACE: "evil-namespace", - GIT_SEQUENCE_EDITOR: "/tmp/git-sequence-editor", - EDITOR: "/tmp/editor", - NPM_CONFIG_USERCONFIG: "/tmp/npmrc", - GIT_CONFIG_GLOBAL: "/tmp/gitconfig", - CARGO_REGISTRIES_CRATES_IO_INDEX: "https://example.invalid/crates.io-index", - AWS_CONFIG_FILE: "/tmp/override-aws-config", - YARN_RC_FILENAME: ".evil-yarnrc.yml", - KUBECONFIG: "/tmp/override-kubeconfig", - GOOGLE_APPLICATION_CREDENTIALS: "/tmp/override-gcp.json", - AWS_SHARED_CREDENTIALS_FILE: "/tmp/override-aws-credentials", - AWS_WEB_IDENTITY_TOKEN_FILE: "/tmp/override-aws-web-token", - AZURE_AUTH_LOCATION: "/tmp/override-azure-auth.json", - PIP_INDEX_URL: "https://example.invalid/simple", - PIP_PYPI_URL: "https://example.invalid/simple", - PIP_EXTRA_INDEX_URL: "https://example.invalid/simple", - PIP_CONFIG_FILE: "/tmp/evil-pip.conf", - PIP_FIND_LINKS: "https://example.invalid/wheels", - PIP_TRUSTED_HOST: "example.invalid", - UV_INDEX: "https://example.invalid/simple", - UV_INDEX_URL: "https://example.invalid/simple", - UV_PYTHON: "/tmp/evil-uv-python", - UV_DEFAULT_INDEX: "https://example.invalid/simple", - UV_EXTRA_INDEX_URL: "https://example.invalid/simple", - DOCKER_HOST: "tcp://example.invalid:2376", - DOCKER_TLS_VERIFY: "1", - DOCKER_CERT_PATH: "/tmp/evil-docker-certs", - DOCKER_CONTEXT: "evil-remote", - LIBRARY_PATH: "/tmp/evil-lib", - CPATH: "/tmp/evil-headers", - C_INCLUDE_PATH: "/tmp/evil-c-headers", - CPLUS_INCLUDE_PATH: "/tmp/evil-cpp-headers", - OBJC_INCLUDE_PATH: "/tmp/evil-objc-headers", - HELM_HOME: "/tmp/override-helm", - BASHOPTS: "xtrace", - FPATH: "/tmp/evil-fpath", - KSH_ENV: "/tmp/evil-ksh-env", - TCLLIBPATH: "/tmp/evil-tcllibpath", - NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log", - NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js", - NODE_REPL_HISTORY: "/tmp/node-repl-history", - NODE_V8_COVERAGE: "/tmp/coverage", - NODE_EXTRA_CA_CERTS: "/tmp/evil-ca.pem", - SSL_CERT_FILE: "/tmp/evil-cert.pem", - 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/*", - GONOPROXY: "example.invalid/*", - GOPRIVATE: "example.invalid/*", - GOENV: "/tmp/evil-goenv", - GOPATH: "/tmp/evil-go", - PYTHONUSERBASE: "/tmp/evil-python-userbase", - VIRTUAL_ENV: "/tmp/evil-venv", - SHELLOPTS: "xtrace", - PS4: "$(touch /tmp/pwned)", - CLASSPATH: "/tmp/evil-classpath", - JAVA_OPTS: "-javaagent:/tmp/evil.jar", - GOFLAGS: "-mod=mod", - RUSTFLAGS: "-C link-args=-l/tmp/evil.so", - MAKEFLAGS: "--eval=$(shell touch /tmp/pwned)", - MFLAGS: "--eval=$(shell touch /tmp/pwned-too)", - PHPRC: "/tmp/evil-php.ini", - XDG_CONFIG_HOME: "/tmp/evil-config", - SAFE: "ok", - }; - const env = sanitizeHostExecEnv({ baseEnv, overrides }); + const baseEnv = envRecord([ + ["PATH", "/usr/bin:/bin"], + ["HOME", "/tmp/trusted-home"], + ["ZDOTDIR", "/tmp/trusted-zdotdir"], + ["CARGO_REGISTRIES_CRATES_IO_INDEX", "https://trusted.example/crates.io-index"], + ["YARN_RC_FILENAME", ".trusted-yarnrc.yml"], + ]); + const overrides = envRecord([ + ["PATH", "/tmp/evil"], + ["HOME", "/tmp/evil-home"], + ["ZDOTDIR", "/tmp/evil-zdotdir"], + ["BASH_ENV", "/tmp/pwn.sh"], + ["BROWSER", "/tmp/browser"], + ["CC", "/tmp/evil-cc"], + ["CXX", "/tmp/evil-cxx"], + ["CARGO_BUILD_RUSTC", "/tmp/evil-rustc"], + ["CARGO_BUILD_RUSTC_WRAPPER", "/tmp/evil-rustc-wrapper"], + ["CMAKE_C_COMPILER", "/tmp/evil-c-compiler"], + ["CMAKE_CXX_COMPILER", "/tmp/evil-cxx-compiler"], + ["RUSTC_WRAPPER", "/tmp/evil-rustc-wrapper"], + ["HGRCPATH", "/tmp/evil-hgrc"], + ["GIT_ALLOW_PROTOCOL", "ext"], + ["GIT_PROTOCOL_FROM_USER", "1"], + ["GIT_SSH_COMMAND", "touch /tmp/pwned"], + ["GIT_EDITOR", "/tmp/git-editor"], + ["GIT_DIR", "/tmp/evil-git-dir"], + ["GIT_WORK_TREE", "/tmp/evil-work-tree"], + ["GIT_COMMON_DIR", "/tmp/evil-common-dir"], + ["GIT_EXEC_PATH", "/tmp/git-exec-path"], + ["GIT_INDEX_FILE", "/tmp/evil-git-index"], + ["GIT_OBJECT_DIRECTORY", "/tmp/evil-git-objects"], + ["GIT_ALTERNATE_OBJECT_DIRECTORIES", "/tmp/evil-git-alt-objects"], + ["GIT_NAMESPACE", "evil-namespace"], + ["GIT_SEQUENCE_EDITOR", "/tmp/git-sequence-editor"], + ["EDITOR", "/tmp/editor"], + ["NPM_CONFIG_USERCONFIG", "/tmp/npmrc"], + ["GIT_CONFIG_GLOBAL", "/tmp/gitconfig"], + ["CARGO_REGISTRIES_CRATES_IO_INDEX", "https://example.invalid/crates.io-index"], + ["AWS_CONFIG_FILE", "/tmp/override-aws-config"], + ["YARN_RC_FILENAME", ".evil-yarnrc.yml"], + ["KUBECONFIG", "/tmp/override-kubeconfig"], + ["GOOGLE_APPLICATION_CREDENTIALS", "/tmp/override-gcp.json"], + ["AWS_SHARED_CREDENTIALS_FILE", "/tmp/override-aws-credentials"], + ["AWS_WEB_IDENTITY_TOKEN_FILE", "/tmp/override-aws-web-token"], + ["AZURE_AUTH_LOCATION", "/tmp/override-azure-auth.json"], + ["PIP_INDEX_URL", "https://example.invalid/simple"], + ["PIP_PYPI_URL", "https://example.invalid/simple"], + ["PIP_EXTRA_INDEX_URL", "https://example.invalid/simple"], + ["PIP_CONFIG_FILE", "/tmp/evil-pip.conf"], + ["PIP_FIND_LINKS", "https://example.invalid/wheels"], + ["PIP_TRUSTED_HOST", "example.invalid"], + ["UV_INDEX", "https://example.invalid/simple"], + ["UV_INDEX_URL", "https://example.invalid/simple"], + ["UV_PYTHON", "/tmp/evil-uv-python"], + ["UV_DEFAULT_INDEX", "https://example.invalid/simple"], + ["UV_EXTRA_INDEX_URL", "https://example.invalid/simple"], + ["DOCKER_HOST", "tcp://example.invalid:2376"], + ["DOCKER_TLS_VERIFY", "1"], + ["DOCKER_CERT_PATH", "/tmp/evil-docker-certs"], + ["DOCKER_CONTEXT", "evil-remote"], + ["LIBRARY_PATH", "/tmp/evil-lib"], + ["CPATH", "/tmp/evil-headers"], + ["C_INCLUDE_PATH", "/tmp/evil-c-headers"], + ["CPLUS_INCLUDE_PATH", "/tmp/evil-cpp-headers"], + ["OBJC_INCLUDE_PATH", "/tmp/evil-objc-headers"], + ["HELM_HOME", "/tmp/override-helm"], + ["BASHOPTS", "xtrace"], + ["FPATH", "/tmp/evil-fpath"], + ["KSH_ENV", "/tmp/evil-ksh-env"], + ["TCLLIBPATH", "/tmp/evil-tcllibpath"], + ["NODE_REDIRECT_WARNINGS", "/tmp/node-warnings.log"], + ["NODE_REPL_EXTERNAL_MODULE", "/tmp/pwn.js"], + ["NODE_REPL_HISTORY", "/tmp/node-repl-history"], + ["NODE_V8_COVERAGE", "/tmp/coverage"], + ["NODE_EXTRA_CA_CERTS", "/tmp/evil-ca.pem"], + ["SSL_CERT_FILE", "/tmp/evil-cert.pem"], + ["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/*"], + ["GONOPROXY", "example.invalid/*"], + ["GOPRIVATE", "example.invalid/*"], + ["GOENV", "/tmp/evil-goenv"], + ["GOPATH", "/tmp/evil-go"], + ["PYTHONUSERBASE", "/tmp/evil-python-userbase"], + ["VIRTUAL_ENV", "/tmp/evil-venv"], + ["SHELLOPTS", "xtrace"], + ["PS4", "$(touch /tmp/pwned)"], + ["CLASSPATH", "/tmp/evil-classpath"], + ["JAVA_OPTS", "-javaagent:/tmp/evil.jar"], + ["GOFLAGS", "-mod=mod"], + ["RUSTFLAGS", "-C link-args=-l/tmp/evil.so"], + ["MAKEFLAGS", "--eval=$(shell touch /tmp/pwned)"], + ["MFLAGS", "--eval=$(shell touch /tmp/pwned-too)"], + ["PHPRC", "/tmp/evil-php.ini"], + ["XDG_CONFIG_HOME", "/tmp/evil-config"], + ["SAFE", "ok"], + ]); + const env = sanitizeHostExecEnv({ + baseEnv, + overrides, + }); expect(env.PATH).toBe("/usr/bin:/bin"); expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.BASH_ENV).toBeUndefined(); expect(env.BROWSER).toBeUndefined(); + expect(env.GIT_ALLOW_PROTOCOL).toBeUndefined(); expect(env.GIT_EDITOR).toBeUndefined(); expect(env.GIT_DIR).toBeUndefined(); expect(env.GIT_WORK_TREE).toBeUndefined(); @@ -517,6 +618,7 @@ describe("sanitizeHostExecEnv", () => { expect(env.GIT_OBJECT_DIRECTORY).toBeUndefined(); expect(env.GIT_ALTERNATE_OBJECT_DIRECTORIES).toBeUndefined(); expect(env.GIT_NAMESPACE).toBeUndefined(); + expect(env.GIT_PROTOCOL_FROM_USER).toBeUndefined(); expect(env.GIT_SEQUENCE_EDITOR).toBeUndefined(); expect(env.AWS_CONFIG_FILE).toBeUndefined(); expect(env.KUBECONFIG).toBeUndefined(); @@ -575,6 +677,10 @@ describe("sanitizeHostExecEnv", () => { expect(env.GOPATH).toBeUndefined(); expect(env.CARGO_HOME).toBeUndefined(); expect(env.HELM_HOME).toBeUndefined(); + expect(env.BASHOPTS).toBeUndefined(); + expect(env.FPATH).toBeUndefined(); + expect(env.KSH_ENV).toBeUndefined(); + expect(env.TCLLIBPATH).toBeUndefined(); expect(env.NODE_REDIRECT_WARNINGS).toBeUndefined(); expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined(); expect(env.NODE_REPL_HISTORY).toBeUndefined(); @@ -994,90 +1100,93 @@ describe("isDangerousHostEnvOverrideVarName", () => { describe("sanitizeHostExecEnvWithDiagnostics", () => { it("reports blocked and invalid requested overrides", () => { - // Diagnostics coverage needs the denied keys present at the sanitizer boundary. - const baseEnv = { - PATH: "/usr/bin:/bin", - }; - const overrides = { - PATH: "/tmp/evil", - CXX: "/tmp/evil-cxx", - CARGO_BUILD_RUSTC_WRAPPER: "/tmp/evil-rustc-wrapper", - CARGO_REGISTRIES_CRATES_IO_INDEX: "https://example.invalid/crates.io-index", - CMAKE_C_COMPILER: "/tmp/evil-c-compiler", - KUBECONFIG: "/tmp/evil-kubeconfig", - GOOGLE_APPLICATION_CREDENTIALS: "/tmp/evil-gcp.json", - AWS_SHARED_CREDENTIALS_FILE: "/tmp/evil-aws-credentials", - AWS_WEB_IDENTITY_TOKEN_FILE: "/tmp/evil-aws-web-token", - AZURE_AUTH_LOCATION: "/tmp/evil-azure-auth.json", - CLASSPATH: "/tmp/evil-classpath", - PIP_INDEX_URL: "https://example.invalid/simple", - PIP_PYPI_URL: "https://example.invalid/simple", - PIP_EXTRA_INDEX_URL: "https://example.invalid/simple", - PIP_CONFIG_FILE: "/tmp/evil-pip.conf", - PIP_FIND_LINKS: "https://example.invalid/wheels", - PIP_TRUSTED_HOST: "example.invalid", - UV_INDEX: "https://example.invalid/simple", - UV_INDEX_URL: "https://example.invalid/simple", - UV_PYTHON: "/tmp/evil-uv-python", - UV_DEFAULT_INDEX: "https://example.invalid/simple", - UV_EXTRA_INDEX_URL: "https://example.invalid/simple", - DOCKER_HOST: "tcp://example.invalid:2376", - DOCKER_TLS_VERIFY: "1", - DOCKER_CERT_PATH: "/tmp/evil-docker-certs", - DOCKER_CONTEXT: "evil-remote", - LIBRARY_PATH: "/tmp/evil-lib", - CPATH: "/tmp/evil-headers", - C_INCLUDE_PATH: "/tmp/evil-c-headers", - CPLUS_INCLUDE_PATH: "/tmp/evil-cpp-headers", - OBJC_INCLUDE_PATH: "/tmp/evil-objc-headers", - NODE_EXTRA_CA_CERTS: "/tmp/evil-ca.pem", - SSL_CERT_FILE: "/tmp/evil-cert.pem", - SSL_CERT_DIR: "/tmp/evil-cert-dir", - REQUESTS_CA_BUNDLE: "/tmp/evil-requests-ca.pem", - CURL_CA_BUNDLE: "/tmp/evil-curl-ca.pem", - GIT_DIR: "/tmp/evil-git-dir", - GIT_WORK_TREE: "/tmp/evil-work-tree", - GIT_COMMON_DIR: "/tmp/evil-common-dir", - GIT_INDEX_FILE: "/tmp/evil-git-index", - GIT_OBJECT_DIRECTORY: "/tmp/evil-git-objects", - GIT_ALTERNATE_OBJECT_DIRECTORIES: "/tmp/evil-git-alt-objects", - GIT_NAMESPACE: "evil-namespace", - GOPROXY: "https://example.invalid/proxy", - GONOSUMCHECK: "example.invalid/*", - GONOSUMDB: "example.invalid/*", - GONOPROXY: "example.invalid/*", - GOPRIVATE: "example.invalid/*", - GOENV: "/tmp/evil-goenv", - GOPATH: "/tmp/evil-go", - CARGO_HOME: "/tmp/evil-cargo", - HGRCPATH: "/tmp/evil-hgrc", - MAKEFLAGS: "--eval=$(shell touch /tmp/pwned)", - MFLAGS: "--eval=$(shell touch /tmp/pwned-too)", - HELM_HOME: "/tmp/evil-helm", - NODE_REDIRECT_WARNINGS: "/tmp/node-warnings.log", - NODE_REPL_EXTERNAL_MODULE: "/tmp/pwn.js", - NODE_REPL_HISTORY: "/tmp/node-repl-history", - NODE_V8_COVERAGE: "/tmp/coverage", - PYTHONUSERBASE: "/tmp/evil-python-userbase", - RUSTC_WRAPPER: "/tmp/evil-rustc-wrapper", - RUSTFLAGS: "-C link-args=-l/tmp/evil.so", - RUSTUP_DIST_ROOT: "https://evil.example.test/deprecated-dist", - RUSTUP_DIST_SERVER: "https://evil.example.test", - RUSTUP_HOME: "/tmp/evil-rustup-home", - RUSTUP_TOOLCHAIN: "/tmp/evil-toolchain", - RUSTUP_UPDATE_ROOT: "https://evil.example.test/rustup", - VIRTUAL_ENV: "/tmp/evil-venv", - JAVA_OPTS: "-javaagent:/tmp/evil.jar", - YARN_RC_FILENAME: ".evil-yarnrc.yml", - 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", - }; - const result = sanitizeHostExecEnvWithDiagnostics({ baseEnv, overrides }); + const overrides = envRecord([ + ["PATH", "/tmp/evil"], + ["CXX", "/tmp/evil-cxx"], + ["CARGO_BUILD_RUSTC_WRAPPER", "/tmp/evil-rustc-wrapper"], + ["CARGO_REGISTRIES_CRATES_IO_INDEX", "https://example.invalid/crates.io-index"], + ["CMAKE_C_COMPILER", "/tmp/evil-c-compiler"], + ["KUBECONFIG", "/tmp/evil-kubeconfig"], + ["GOOGLE_APPLICATION_CREDENTIALS", "/tmp/evil-gcp.json"], + ["AWS_SHARED_CREDENTIALS_FILE", "/tmp/evil-aws-credentials"], + ["AWS_WEB_IDENTITY_TOKEN_FILE", "/tmp/evil-aws-web-token"], + ["AZURE_AUTH_LOCATION", "/tmp/evil-azure-auth.json"], + ["CLASSPATH", "/tmp/evil-classpath"], + ["PIP_INDEX_URL", "https://example.invalid/simple"], + ["PIP_PYPI_URL", "https://example.invalid/simple"], + ["PIP_EXTRA_INDEX_URL", "https://example.invalid/simple"], + ["PIP_CONFIG_FILE", "/tmp/evil-pip.conf"], + ["PIP_FIND_LINKS", "https://example.invalid/wheels"], + ["PIP_TRUSTED_HOST", "example.invalid"], + ["UV_INDEX", "https://example.invalid/simple"], + ["UV_INDEX_URL", "https://example.invalid/simple"], + ["UV_PYTHON", "/tmp/evil-uv-python"], + ["UV_DEFAULT_INDEX", "https://example.invalid/simple"], + ["UV_EXTRA_INDEX_URL", "https://example.invalid/simple"], + ["DOCKER_HOST", "tcp://example.invalid:2376"], + ["DOCKER_TLS_VERIFY", "1"], + ["DOCKER_CERT_PATH", "/tmp/evil-docker-certs"], + ["DOCKER_CONTEXT", "evil-remote"], + ["LIBRARY_PATH", "/tmp/evil-lib"], + ["CPATH", "/tmp/evil-headers"], + ["C_INCLUDE_PATH", "/tmp/evil-c-headers"], + ["CPLUS_INCLUDE_PATH", "/tmp/evil-cpp-headers"], + ["OBJC_INCLUDE_PATH", "/tmp/evil-objc-headers"], + ["NODE_EXTRA_CA_CERTS", "/tmp/evil-ca.pem"], + ["SSL_CERT_FILE", "/tmp/evil-cert.pem"], + ["SSL_CERT_DIR", "/tmp/evil-cert-dir"], + ["REQUESTS_CA_BUNDLE", "/tmp/evil-requests-ca.pem"], + ["CURL_CA_BUNDLE", "/tmp/evil-curl-ca.pem"], + ["GIT_ALLOW_PROTOCOL", "ext"], + ["GIT_DIR", "/tmp/evil-git-dir"], + ["GIT_WORK_TREE", "/tmp/evil-work-tree"], + ["GIT_COMMON_DIR", "/tmp/evil-common-dir"], + ["GIT_INDEX_FILE", "/tmp/evil-git-index"], + ["GIT_OBJECT_DIRECTORY", "/tmp/evil-git-objects"], + ["GIT_ALTERNATE_OBJECT_DIRECTORIES", "/tmp/evil-git-alt-objects"], + ["GIT_NAMESPACE", "evil-namespace"], + ["GIT_PROTOCOL_FROM_USER", "1"], + ["GOPROXY", "https://example.invalid/proxy"], + ["GONOSUMCHECK", "example.invalid/*"], + ["GONOSUMDB", "example.invalid/*"], + ["GONOPROXY", "example.invalid/*"], + ["GOPRIVATE", "example.invalid/*"], + ["GOENV", "/tmp/evil-goenv"], + ["GOPATH", "/tmp/evil-go"], + ["CARGO_HOME", "/tmp/evil-cargo"], + ["HGRCPATH", "/tmp/evil-hgrc"], + ["MAKEFLAGS", "--eval=$(shell touch /tmp/pwned)"], + ["MFLAGS", "--eval=$(shell touch /tmp/pwned-too)"], + ["HELM_HOME", "/tmp/evil-helm"], + ["NODE_REDIRECT_WARNINGS", "/tmp/node-warnings.log"], + ["NODE_REPL_EXTERNAL_MODULE", "/tmp/pwn.js"], + ["NODE_REPL_HISTORY", "/tmp/node-repl-history"], + ["NODE_V8_COVERAGE", "/tmp/coverage"], + ["PYTHONUSERBASE", "/tmp/evil-python-userbase"], + ["RUSTC_WRAPPER", "/tmp/evil-rustc-wrapper"], + ["RUSTFLAGS", "-C link-args=-l/tmp/evil.so"], + ["RUSTUP_DIST_ROOT", "https://evil.example.test/deprecated-dist"], + ["RUSTUP_DIST_SERVER", "https://evil.example.test"], + ["RUSTUP_HOME", "/tmp/evil-rustup-home"], + ["RUSTUP_TOOLCHAIN", "/tmp/evil-toolchain"], + ["RUSTUP_UPDATE_ROOT", "https://evil.example.test/rustup"], + ["VIRTUAL_ENV", "/tmp/evil-venv"], + ["JAVA_OPTS", "-javaagent:/tmp/evil.jar"], + ["YARN_RC_FILENAME", ".evil-yarnrc.yml"], + ["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"], + ]); + const result = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { + PATH: "/usr/bin:/bin", + }, + overrides, + }); expect(result.rejectedOverrideBlockedKeys).toEqual([ "AWS_SHARED_CREDENTIALS_FILE", @@ -1097,12 +1206,14 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { "DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS_VERIFY", + "GIT_ALLOW_PROTOCOL", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_DIR", "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", + "GIT_PROTOCOL_FROM_USER", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", @@ -1204,6 +1315,8 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { expect(result.env.GIT_ALTERNATE_OBJECT_DIRECTORIES).toBeUndefined(); expect(result.env.GIT_OBJECT_DIRECTORY).toBeUndefined(); expect(result.env.GIT_NAMESPACE).toBeUndefined(); + expect(result.env.GIT_ALLOW_PROTOCOL).toBeUndefined(); + expect(result.env.GIT_PROTOCOL_FROM_USER).toBeUndefined(); expect(result.env.GOPROXY).toBeUndefined(); expect(result.env.GONOSUMCHECK).toBeUndefined(); expect(result.env.GONOSUMDB).toBeUndefined(); @@ -1642,6 +1755,191 @@ describe("git env exploit regression", () => { } }); + it("blocks inherited GIT_ALLOW_PROTOCOL so git cannot enable ext transport helpers", async () => { + const gitPath = getSystemGitPath(); + if (!gitPath) { + return; + } + + const helperDir = fs.mkdtempSync( + path.join(os.tmpdir(), `openclaw-git-allow-protocol-${process.pid}-${Date.now()}-`), + ); + const helperPath = path.join(helperDir, "ext-helper.sh"); + const marker = path.join( + os.tmpdir(), + `openclaw-git-allow-protocol-marker-${process.pid}-${Date.now()}`, + ); + + try { + clearMarker(marker); + fs.writeFileSync(helperPath, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nexit 1\n`, "utf8"); + fs.chmodSync(helperPath, 0o755); + + const target = `ext::${helperPath}`; + const unsafeEnv = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + GIT_ALLOW_PROTOCOL: "ext", + GIT_TERMINAL_PROMPT: "0", + }; + + await runGitLsRemote(gitPath, target, unsafeEnv); + + expect(fs.existsSync(marker)).toBe(true); + clearMarker(marker); + + const safeEnv = sanitizeHostExecEnv({ + baseEnv: unsafeEnv, + }); + + await runGitLsRemote(gitPath, target, safeEnv); + + expect(fs.existsSync(marker)).toBe(false); + } finally { + fs.rmSync(helperDir, { recursive: true, force: true }); + fs.rmSync(marker, { force: true }); + } + }); + + it("filters inherited GIT_ALLOW_PROTOCOL without widening file transport access", async () => { + const gitPath = getSystemGitPath(); + if (!gitPath) { + return; + } + + const repoDir = fs.mkdtempSync( + path.join(os.tmpdir(), `openclaw-git-allow-protocol-source-${process.pid}-${Date.now()}-`), + ); + const cloneDir = path.join( + os.tmpdir(), + `openclaw-git-allow-protocol-clone-${process.pid}-${Date.now()}`, + ); + + try { + await runGitCommand(gitPath, ["init", repoDir]); + await runGitCommand( + gitPath, + [ + "-C", + repoDir, + "-c", + "user.name=OpenClaw Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "init", + ], + { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + }, + ); + + const inheritedEnv = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + GIT_ALLOW_PROTOCOL: "https::ssh", + GIT_TERMINAL_PROMPT: "0", + }; + const unsafeExitCode = await runGitCommandExitCode(gitPath, ["clone", repoDir, cloneDir], { + env: inheritedEnv, + }); + + expect(unsafeExitCode).not.toBe(0); + + const safeEnv = sanitizeHostExecEnv({ + baseEnv: inheritedEnv, + }); + + expect(safeEnv.GIT_ALLOW_PROTOCOL).toBe("https:ssh"); + + const safeExitCode = await runGitCommandExitCode(gitPath, ["clone", repoDir, cloneDir], { + env: safeEnv, + }); + + expect(safeExitCode).not.toBe(0); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(cloneDir, { recursive: true, force: true }); + } + }); + + it("forces inherited permissive GIT_PROTOCOL_FROM_USER to block file transport access", async () => { + const gitPath = getSystemGitPath(); + if (!gitPath) { + return; + } + + const repoDir = fs.mkdtempSync( + path.join( + os.tmpdir(), + `openclaw-git-protocol-from-user-source-${process.pid}-${Date.now()}-`, + ), + ); + const unsafeCloneDir = path.join( + os.tmpdir(), + `openclaw-git-protocol-from-user-unsafe-${process.pid}-${Date.now()}`, + ); + const safeCloneDir = path.join( + os.tmpdir(), + `openclaw-git-protocol-from-user-safe-${process.pid}-${Date.now()}`, + ); + + try { + await runGitCommand(gitPath, ["init", repoDir]); + await runGitCommand( + gitPath, + [ + "-C", + repoDir, + "-c", + "user.name=OpenClaw Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "init", + ], + { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + }, + ); + + const inheritedEnv = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + GIT_PROTOCOL_FROM_USER: "1", + GIT_TERMINAL_PROMPT: "0", + }; + const unsafeExitCode = await runGitCommandExitCode( + gitPath, + ["clone", repoDir, unsafeCloneDir], + { env: inheritedEnv }, + ); + + expect(unsafeExitCode).toBe(0); + + const safeEnv = sanitizeHostExecEnv({ + baseEnv: inheritedEnv, + }); + + expect(safeEnv.GIT_PROTOCOL_FROM_USER).toBe("0"); + + const safeExitCode = await runGitCommandExitCode(gitPath, ["clone", repoDir, safeCloneDir], { + env: safeEnv, + }); + + expect(safeExitCode).not.toBe(0); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(unsafeCloneDir, { recursive: true, force: true }); + fs.rmSync(safeCloneDir, { recursive: true, force: true }); + } + }); + it("blocks GIT_SSH_COMMAND override so git cannot execute helper payloads", async () => { const gitPath = getSystemGitPath(); if (!gitPath) { @@ -1653,24 +1951,23 @@ describe("git env exploit regression", () => { const target = "ssh://127.0.0.1:1/does-not-matter"; const exploitValue = `touch ${JSON.stringify(marker)}; false`; + const gitSshCommandKey = "GIT_SSH_COMMAND"; const baseEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin", GIT_TERMINAL_PROMPT: "0", }; - const unsafeEnv = { - ...baseEnv, - GIT_SSH_COMMAND: exploitValue, - }; + const unsafeEnv = envRecord([...Object.entries(baseEnv), [gitSshCommandKey, exploitValue]]); await runGitLsRemote(gitPath, target, unsafeEnv); expect(fs.existsSync(marker)).toBe(true); clearMarker(marker); - // Exploit regression proves the sanitizer removes the Git command pivot before spawn. - const safeOverrides = { GIT_SSH_COMMAND: exploitValue }; - const safeEnv = sanitizeHostExecEnv({ baseEnv, overrides: safeOverrides }); + const safeEnv = sanitizeHostExecEnv({ + baseEnv, + overrides: envRecord([[gitSshCommandKey, exploitValue]]), + }); await runGitLsRemote(gitPath, target, safeEnv); @@ -1709,18 +2006,21 @@ describe("compiler override exploit regression", () => { const baseEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin", }; + const compilerKey = "CC"; - await runMakeCommand(makePath, tempDir, { - ...baseEnv, - CC: exploitPath, - }); + await runMakeCommand( + makePath, + tempDir, + envRecord([...Object.entries(baseEnv), [compilerKey, exploitPath]]), + ); expect(fs.existsSync(marker)).toBe(true); clearMarker(marker); - // Exploit regression proves compiler override pivots are blocked before make runs. - const safeOverrides = { CC: exploitPath }; - const safeEnv = sanitizeHostExecEnv({ baseEnv, overrides: safeOverrides }); + const safeEnv = sanitizeHostExecEnv({ + baseEnv, + overrides: envRecord([[compilerKey, exploitPath]]), + }); await runMakeCommand(makePath, tempDir, safeEnv); @@ -1755,18 +2055,21 @@ describe("make env exploit regression", () => { const baseEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin", }; + const makeFlagsKey = "MAKEFLAGS"; - await runMakeCommand(makePath, tempDir, { - ...baseEnv, - MAKEFLAGS: exploitValue, - }); + await runMakeCommand( + makePath, + tempDir, + envRecord([...Object.entries(baseEnv), [makeFlagsKey, exploitValue]]), + ); const baselineTriggered = fs.existsSync(marker); clearMarker(marker); - // Exploit regression proves make flag pivots are blocked before child execution. - const safeOverrides = { MAKEFLAGS: exploitValue }; - const safeEnv = sanitizeHostExecEnv({ baseEnv, overrides: safeOverrides }); + const safeEnv = sanitizeHostExecEnv({ + baseEnv, + overrides: envRecord([[makeFlagsKey, exploitValue]]), + }); expect(safeEnv.MAKEFLAGS).toBeUndefined(); await runMakeCommand(makePath, tempDir, safeEnv); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index e48347f06725..1b71167239bf 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -43,6 +43,10 @@ const HOST_DANGEROUS_OVERRIDE_ENV_KEYS = new Set(HOST_DANGEROUS_OVERRIDE const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set( HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES, ); +const GIT_ALLOW_PROTOCOL_ENV_KEY = "GIT_ALLOW_PROTOCOL"; +const GIT_PROTOCOL_FROM_USER_ENV_KEY = "GIT_PROTOCOL_FROM_USER"; +const GIT_PROTOCOL_FROM_USER_DISABLED_VALUE = "0"; +const GIT_DEFAULT_ALWAYS_ALLOWED_PROTOCOLS = new Set(["git", "http", "https", "ssh"]); function isShellWrapperAllowedOverrideEnvVarName(rawKey: string): boolean { const key = normalizeEnvVarKey(rawKey, { portable: true }); @@ -148,6 +152,55 @@ function listNormalizedEnvEntries( return entries; } +function isPermissiveGitProtocolFromUserValue(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "yes" || normalized === "on") { + return true; + } + if (/^[+-]?\d+$/.test(normalized) && !/^[+-]?0+$/.test(normalized)) { + return true; + } + return false; +} + +function sanitizeInheritedGitAllowProtocolValue(value: string): string { + const normalized = value.trim(); + if (!normalized) { + return ""; + } + const safeProtocols = normalized + .split(":") + .filter((protocol) => GIT_DEFAULT_ALWAYS_ALLOWED_PROTOCOLS.has(protocol)); + return safeProtocols.join(":"); +} + +export function sanitizeHostInheritedEnvEntry( + rawKey: string, + value: string, +): [string, string] | null { + const key = normalizeEnvVarKey(rawKey); + if (!key) { + return null; + } + // Preserve inherited Git allowlists without widening malformed or unsafe entries by deletion. + // Protocols outside Git's safe default set are removed instead of being passed through. + if (key.toUpperCase() === GIT_ALLOW_PROTOCOL_ENV_KEY) { + return [key, sanitizeInheritedGitAllowProtocolValue(value)]; + } + // Preserve non-permissive Git boolean values. Permissive values must become explicit `0` + // because Git's unset default still permits protocols with policy `user`. + if (key.toUpperCase() === GIT_PROTOCOL_FROM_USER_ENV_KEY) { + return [ + key, + isPermissiveGitProtocolFromUserValue(value) ? GIT_PROTOCOL_FROM_USER_DISABLED_VALUE : value, + ]; + } + if (isDangerousHostInheritedEnvVarName(key)) { + return null; + } + return [key, value]; +} + function sanitizeHostEnvOverridesWithDiagnostics(params?: { overrides?: Record | null; blockPathOverrides?: boolean; @@ -210,10 +263,12 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: { const merged: Record = {}; for (const [key, value] of listNormalizedEnvEntries(baseEnv)) { - if (isDangerousHostInheritedEnvVarName(key)) { + const sanitizedEntry = sanitizeHostInheritedEnvEntry(key, value); + if (!sanitizedEntry) { continue; } - merged[key] = value; + const [sanitizedKey, sanitizedValue] = sanitizedEntry; + merged[sanitizedKey] = sanitizedValue; } const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({