Low severity3.3NVD Advisory· Published Apr 23, 2026· Updated Apr 29, 2026
CVE-2026-41357
CVE-2026-41357
Description
OpenClaw before 2026.3.31 contains an environment variable leakage vulnerability in SSH-based sandbox backends that pass unsanitized process.env to child processes. Attackers can exploit this by leveraging non-default SSH environment forwarding configurations to leak sensitive environment variables from parent processes to SSH child processes.
Affected products
1Patches
1cfe14459531eSandbox: sanitize SSH subprocess env (#57848)
12 files changed · +1005 −320
docs/.generated/plugin-sdk-api-baseline.json+606 −174 modifieddocs/.generated/plugin-sdk-api-baseline.jsonl+187 −139 modifiedextensions/openshell/src/backend.test.ts+29 −0 added@@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { buildOpenShellSshExecEnv } from "./backend.js"; + +describe("openshell backend env", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); + }); + + it("filters blocked secrets from ssh exec env", () => { + process.env.OPENAI_API_KEY = "sk-test-secret"; + process.env.ANTHROPIC_API_KEY = "sk-ant-test-secret"; + process.env.LANG = "en_US.UTF-8"; + process.env.NODE_ENV = "test"; + + const env = buildOpenShellSshExecEnv(); + + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.LANG).toBe("en_US.UTF-8"); + expect(env.NODE_ENV).toBe("test"); + }); +});
extensions/openshell/src/backend.ts+7 −2 modified@@ -17,6 +17,7 @@ import { disposeSshSandboxSession, resolvePreferredOpenClawTmpDir, runSshSandboxCommand, + sanitizeEnvVars, } from "openclaw/plugin-sdk/sandbox"; import { buildExecRemoteCommand, @@ -41,6 +42,10 @@ type PendingExec = { sshSession: SshSandboxSession; }; +export function buildOpenShellSshExecEnv(): NodeJS.ProcessEnv { + return sanitizeEnvVars(process.env).allowed; +} + export type OpenShellSandboxBackend = SandboxBackendHandle & RemoteShellSandboxHandle & { mode: "mirror" | "remote"; @@ -123,7 +128,7 @@ async function createOpenShellSandboxBackend(params: { const pending = await impl.prepareExec({ command, workdir, env, usePty }); return { argv: pending.argv, - env: process.env, + env: buildOpenShellSshExecEnv(), stdinMode: "pipe-open", finalizeToken: pending.token, }; @@ -180,7 +185,7 @@ class OpenShellSandboxBackendImpl { const pending = await self.prepareExec({ command, workdir, env, usePty }); return { argv: pending.argv, - env: process.env, + env: buildOpenShellSshExecEnv(), stdinMode: "pipe-open", finalizeToken: pending.token, };
src/agents/sandbox/sanitize-env-vars.test.ts+11 −0 modified@@ -54,4 +54,15 @@ describe("sanitizeEnvVars", () => { expect(result.allowed).toEqual({ NODE_ENV: "test" }); expect(result.blocked).toEqual(["FOO"]); }); + + it("skips undefined values when sanitizing process-style env maps", () => { + const result = sanitizeEnvVars({ + NODE_ENV: "test", + OPTIONAL_SECRET: undefined, + OPENAI_API_KEY: undefined, + }); + + expect(result.allowed).toEqual({ NODE_ENV: "test" }); + expect(result.blocked).toEqual([]); + }); });
src/agents/sandbox/sanitize-env-vars.ts+2 −2 modified@@ -60,7 +60,7 @@ function matchesAnyPattern(value: string, patterns: readonly RegExp[]): boolean } export function sanitizeEnvVars( - envVars: Record<string, string>, + envVars: Record<string, string | undefined>, options: EnvSanitizationOptions = {}, ): EnvVarSanitizationResult { const allowed: Record<string, string> = {}; @@ -72,7 +72,7 @@ export function sanitizeEnvVars( for (const [rawKey, value] of Object.entries(envVars)) { const key = rawKey.trim(); - if (!key) { + if (!key || value === undefined) { continue; }
src/agents/sandbox/ssh-backend.test.ts+31 −0 modified@@ -135,6 +135,8 @@ async function expectBackendCreationToReject(params: { } describe("ssh sandbox backend", () => { + const originalEnv = { ...process.env }; + beforeEach(async () => { vi.clearAllMocks(); sshMocks.createSshSandboxSessionFromSettings.mockResolvedValue(createSession()); @@ -157,6 +159,12 @@ describe("ssh sandbox backend", () => { }); afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); vi.restoreAllMocks(); }); @@ -316,6 +324,29 @@ describe("ssh sandbox backend", () => { expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalled(); }); + it("filters blocked secrets from exec subprocess env", async () => { + process.env.OPENAI_API_KEY = "sk-test-secret"; + process.env.LANG = "en_US.UTF-8"; + const backend = await createSshSandboxBackend({ + sessionKey: "agent:worker:task", + scopeKey: "agent:worker", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/agent", + cfg: createBackendSandboxConfig({ + target: "peter@example.com:2222", + }), + }); + + const execSpec = await backend.buildExecSpec({ + command: "pwd", + env: {}, + usePty: false, + }); + + expect(execSpec.env?.OPENAI_API_KEY).toBeUndefined(); + expect(execSpec.env?.LANG).toBe("en_US.UTF-8"); + }); + it("rejects docker binds and missing ssh target", async () => { await expectBackendCreationToReject({ binds: ["/tmp:/tmp:rw"],
src/agents/sandbox/ssh-backend.ts+2 −1 modified@@ -11,6 +11,7 @@ import { createRemoteShellSandboxFsBridge, type RemoteShellSandboxHandle, } from "./remote-fs-bridge.js"; +import { sanitizeEnvVars } from "./sanitize-env-vars.js"; import { buildExecRemoteCommand, buildRemoteCommand, @@ -152,7 +153,7 @@ class SshSandboxBackendImpl { remoteCommand, tty: usePty, }), - env: process.env, + env: sanitizeEnvVars(process.env).allowed, stdinMode: "pipe-open", finalizeToken: { sshSession } satisfies PendingExec, };
src/agents/sandbox/ssh.spawn-env.test.ts+123 −0 added@@ -0,0 +1,123 @@ +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +type MockChildProcess = EventEmitter & { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType<typeof vi.fn>; +}; + +function createMockChildProcess(): MockChildProcess { + const child = new EventEmitter() as MockChildProcess; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal<typeof import("node:child_process")>(); + return { + ...actual, + spawn: spawnMock, + }; +}); + +let runSshSandboxCommand: typeof import("./ssh.js").runSshSandboxCommand; +let uploadDirectoryToSshTarget: typeof import("./ssh.js").uploadDirectoryToSshTarget; + +beforeAll(async () => { + ({ runSshSandboxCommand, uploadDirectoryToSshTarget } = await import("./ssh.js")); +}); + +describe("ssh subprocess env sanitization", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); + }); + + it("filters blocked secrets before spawning ssh commands", async () => { + spawnMock.mockImplementationOnce( + (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { + const child = createMockChildProcess(); + process.nextTick(() => { + child.emit("close", 0); + }); + return child as unknown as ChildProcess; + }, + ); + + process.env.OPENAI_API_KEY = "sk-test-secret"; + process.env.LANG = "en_US.UTF-8"; + + await runSshSandboxCommand({ + session: { + command: "ssh", + configPath: "/tmp/openclaw-test-ssh-config", + host: "openclaw-sandbox", + }, + remoteCommand: "true", + }); + + const spawnOptions = spawnMock.mock.calls[0]?.[2] as SpawnOptions | undefined; + const env = spawnOptions?.env; + expect(env?.OPENAI_API_KEY).toBeUndefined(); + expect(env?.LANG).toBe("en_US.UTF-8"); + }); + + it("filters blocked secrets before spawning ssh uploads", async () => { + spawnMock + .mockImplementationOnce( + (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { + const child = createMockChildProcess(); + process.nextTick(() => { + child.emit("close", 0); + }); + return child as unknown as ChildProcess; + }, + ) + .mockImplementationOnce( + (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => { + const child = createMockChildProcess(); + process.nextTick(() => { + child.emit("close", 0); + }); + return child as unknown as ChildProcess; + }, + ); + + process.env.ANTHROPIC_API_KEY = "sk-test-secret"; + process.env.NODE_ENV = "test"; + + await uploadDirectoryToSshTarget({ + session: { + command: "ssh", + configPath: "/tmp/openclaw-test-ssh-config", + host: "openclaw-sandbox", + }, + localDir: "/tmp/workspace", + remoteDir: "/remote/workspace", + }); + + const sshSpawnOptions = spawnMock.mock.calls[1]?.[2] as SpawnOptions | undefined; + const env = sshSpawnOptions?.env; + expect(env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env?.NODE_ENV).toBe("test"); + }); +});
src/agents/sandbox/ssh.ts+5 −2 modified@@ -6,6 +6,7 @@ import { parseSshTarget } from "../../infra/ssh-tunnel.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { resolveUserPath } from "../../utils.js"; import type { SandboxBackendCommandResult } from "./backend.js"; +import { sanitizeEnvVars } from "./sanitize-env-vars.js"; export type SshSandboxSettings = { command: string; @@ -212,10 +213,11 @@ export async function runSshSandboxCommand( remoteCommand: params.remoteCommand, tty: params.tty, }); + const sshEnv = sanitizeEnvVars(process.env).allowed; return await new Promise<SandboxBackendCommandResult>((resolve, reject) => { const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"], - env: process.env, + env: sshEnv, signal: params.signal, }); const stdoutChunks: Buffer[] = []; @@ -266,14 +268,15 @@ export async function uploadDirectoryToSshTarget(params: { session: params.session, remoteCommand, }); + const sshEnv = sanitizeEnvVars(process.env).allowed; await new Promise<void>((resolve, reject) => { const tar = spawn("tar", ["-C", params.localDir, "-cf", "-", "."], { stdio: ["ignore", "pipe", "pipe"], signal: params.signal, }); const ssh = spawn(sshArgv[0], sshArgv.slice(1), { stdio: ["pipe", "pipe", "pipe"], - env: process.env, + env: sshEnv, signal: params.signal, }); const tarStderr: Buffer[] = [];
src/agents/sandbox.ts+1 −0 modified@@ -45,6 +45,7 @@ export { shellEscape, uploadDirectoryToSshTarget, } from "./sandbox/ssh.js"; +export { sanitizeEnvVars } from "./sandbox/sanitize-env-vars.js"; export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js"; export { createWritableRenameTargetResolver } from "./sandbox/fs-bridge-rename-targets.js"; export { resolveWritableRenameTargets } from "./sandbox/fs-bridge-rename-targets.js";
src/plugin-sdk/sandbox.ts+1 −0 modified@@ -37,6 +37,7 @@ export { resolveWritableRenameTargets, resolveWritableRenameTargetsForBridge, runSshSandboxCommand, + sanitizeEnvVars, shellEscape, uploadDirectoryToSshTarget, } from "../agents/sandbox.js";
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.