diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c53f815bd..8db76d22302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Docs: https://docs.openclaw.ai - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. -- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. ## 2026.2.23 (Unreleased) diff --git a/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts index 8c411e08775..8fddcccc0b8 100644 --- a/src/browser/server.agent-contract-snapshot-endpoints.test.ts +++ b/src/browser/server.agent-contract-snapshot-endpoints.test.ts @@ -68,6 +68,9 @@ describe("browser control server", () => { cdpUrl: state.cdpBaseUrl, targetId: "abcd1234", url: "https://example.com", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, }); const click = await postJson<{ ok: boolean }>(`${base}/act`, { diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 60337d2c098..2e7702714be 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -254,6 +254,35 @@ describe("exec approvals command resolution", () => { expect(resolution?.rawExecutable).toBe("/usr/bin/env"); }); + it("fails closed for env -S even when env itself is allowlisted", () => { + const dir = makeTempDir(); + const binDir = path.join(dir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const envName = process.platform === "win32" ? "env.exe" : "env"; + const envPath = path.join(binDir, envName); + fs.writeFileSync(envPath, process.platform === "win32" ? "" : "#!/bin/sh\n"); + if (process.platform !== "win32") { + fs.chmodSync(envPath, 0o755); + } + + const analysis = analyzeArgvCommand({ + argv: [envPath, "-S", 'sh -c "echo pwned"'], + cwd: dir, + env: makePathEnv(binDir), + }); + const allowlistEval = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: envPath }], + safeBins: normalizeSafeBins([]), + cwd: dir, + }); + + expect(analysis.ok).toBe(true); + expect(analysis.segments[0]?.resolution?.policyBlocked).toBe(true); + expect(allowlistEval.allowlistSatisfied).toBe(false); + expect(allowlistEval.segmentSatisfiedBy).toEqual([null]); + }); + it("unwraps env wrapper with shell inner executable", () => { const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]); expect(resolution?.rawExecutable).toBe("bash"); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index dace3aeffeb..bffe6c638ba 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -150,7 +150,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }), ); }); - it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => { const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); const runCommand = vi.fn(async () => { @@ -213,4 +212,21 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { // no-op } }); + + it("denies env -S shell payloads in allowlist mode", async () => { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "allowlist", + command: ["env", "-S", 'sh -c "echo pwned"'], + }); + expect(runCommand).not.toHaveBeenCalled(); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("allowlist miss"), + }), + }), + ); + }); });