Moderate severityNVD Advisory· Published Mar 21, 2026· Updated Mar 23, 2026
OpenClaw < 2026.2.21 - OS-level Sandbox Bypass via --no-sandbox Flag
CVE-2026-32046
Description
OpenClaw versions prior to 2026.2.21 contain an improper sandbox configuration vulnerability that allows attackers to execute arbitrary code by exploiting renderer-side vulnerabilities without requiring a sandbox escape. Attackers can leverage the disabled OS-level sandbox protections in the Chromium browser container to achieve code execution on the host system.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.21 | 2026.2.21 |
Affected products
1Patches
21835dec2004ffix(security): force sandbox browser hash migration and audit stale labels
12 files changed · +254 −6
CHANGELOG.md+1 −1 modified@@ -56,7 +56,7 @@ Docs: https://docs.openclaw.ai - Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky. - Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc. - Security/Gateway: parse `X-Forwarded-For` with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc. -- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and harden the default container security posture (`GHSA-43x4-g22p-3hrq`). Thanks @TerminalsandCoffee and @vincentkoc. +- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and add security-audit checks for stale/missing sandbox browser Docker hash labels. This ships in the next npm release. Thanks @TerminalsandCoffee and @vincentkoc. - Auto-reply/Tools: forward `senderIsOwner` through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj. - Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow. - Memory/QMD: respect per-agent `memorySearch.enabled=false` during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (`search`/`vsearch`/`query`) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip `qmd embed` in BM25-only `search` mode (including `memory index --force`), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel.
docs/cli/security.md+1 −0 modified@@ -28,6 +28,7 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy. +It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
src/agents/sandbox/browser.ts+10 −2 modified@@ -10,7 +10,11 @@ import { defaultRuntime } from "../../runtime.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { computeSandboxBrowserConfigHash } from "./config-hash.js"; import { resolveSandboxBrowserDockerCreateConfig } from "./config.js"; -import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +import { + DEFAULT_SANDBOX_BROWSER_IMAGE, + SANDBOX_AGENT_WORKSPACE_MOUNT, + SANDBOX_BROWSER_SECURITY_HASH_EPOCH, +} from "./constants.js"; import { buildSandboxCreateArgs, dockerContainerState, @@ -125,6 +129,7 @@ export async function ensureSandboxBrowser(params: { headless: params.cfg.browser.headless, enableNoVnc: params.cfg.browser.enableNoVnc, }, + securityEpoch: SANDBOX_BROWSER_SECURITY_HASH_EPOCH, workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, @@ -177,7 +182,10 @@ export async function ensureSandboxBrowser(params: { name: containerName, cfg: browserDockerCfg, scopeKey: params.scopeKey, - labels: { "openclaw.sandboxBrowser": "1" }, + labels: { + "openclaw.sandboxBrowser": "1", + "openclaw.browserConfigEpoch": SANDBOX_BROWSER_SECURITY_HASH_EPOCH, + }, configHash: expectedHash, }); const mainMountSuffix =
src/agents/sandbox/config-hash.test.ts+26 −0 modified@@ -115,6 +115,7 @@ describe("computeSandboxBrowserConfigHash", () => { headless: false, enableNoVnc: true, }, + securityEpoch: "epoch-v1", workspaceAccess: "rw" as const, workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", @@ -133,4 +134,29 @@ describe("computeSandboxBrowserConfigHash", () => { }); expect(left).not.toBe(right); }); + + it("changes when security epoch changes", () => { + const shared = { + docker: createDockerConfig(), + browser: { + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: false, + enableNoVnc: true, + }, + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxBrowserConfigHash({ + ...shared, + securityEpoch: "epoch-v1", + }); + const right = computeSandboxBrowserConfigHash({ + ...shared, + securityEpoch: "epoch-v2", + }); + expect(left).not.toBe(right); + }); });
src/agents/sandbox/config-hash.ts+1 −0 modified@@ -14,6 +14,7 @@ type SandboxBrowserHashInput = { SandboxBrowserConfig, "cdpPort" | "vncPort" | "noVncPort" | "headless" | "enableNoVnc" >; + securityEpoch: string; workspaceAccess: SandboxWorkspaceAccess; workspaceDir: string; agentWorkspaceDir: string;
src/agents/sandbox/constants.ts+1 −0 modified@@ -38,6 +38,7 @@ export const DEFAULT_TOOL_DENY = [ export const DEFAULT_SANDBOX_BROWSER_IMAGE = "openclaw-sandbox-browser:bookworm-slim"; export const DEFAULT_SANDBOX_COMMON_IMAGE = "openclaw-sandbox-common:bookworm-slim"; +export const SANDBOX_BROWSER_SECURITY_HASH_EPOCH = "2026-02-21-no-sandbox-default"; export const DEFAULT_SANDBOX_BROWSER_PREFIX = "openclaw-sbx-browser-"; export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
src/discord/voice/manager.ts+7 −2 modified@@ -148,14 +148,19 @@ type OpusDecoder = { function createOpusDecoder(): { decoder: OpusDecoder; name: string } | null { try { - const OpusScript = require("opusscript") as typeof import("opusscript"); + const OpusScript = require("opusscript") as { + new (sampleRate: number, channels: number, application: number): OpusDecoder; + Application: { AUDIO: number }; + }; const decoder = new OpusScript(SAMPLE_RATE, CHANNELS, OpusScript.Application.AUDIO); return { decoder, name: "opusscript" }; } catch (err) { logger.warn(`discord voice: opusscript init failed: ${formatErrorMessage(err)}`); } try { - const { OpusEncoder } = require("@discordjs/opus") as typeof import("@discordjs/opus"); + const { OpusEncoder } = require("@discordjs/opus") as { + OpusEncoder: new (sampleRate: number, channels: number) => OpusDecoder; + }; const decoder = new OpusEncoder(SAMPLE_RATE, CHANNELS); return { decoder, name: "@discordjs/opus" }; } catch (err) {
src/gateway/http-auth-helpers.test.ts+1 −1 modified@@ -25,7 +25,7 @@ describe("authorizeGatewayBearerRequestOrReply", () => { }); it("disables tailscale header auth for HTTP bearer checks", async () => { - vi.mocked(getBearerToken).mockReturnValue(null); + vi.mocked(getBearerToken).mockReturnValue(undefined); vi.mocked(authorizeGatewayConnect).mockResolvedValue({ ok: false, reason: "token_missing",
src/security/audit-extra.async.ts+117 −0 modified@@ -11,10 +11,13 @@ import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; +import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants.js"; +import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; +import { formatCliCommand } from "../cli/command-format.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; @@ -44,6 +47,11 @@ export type SecurityAuditFinding = { remediation?: string; }; +type ExecDockerRawFn = ( + args: string[], + opts?: { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal }, +) => Promise<ExecDockerRawResult>; + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- @@ -242,6 +250,115 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi // Exported collectors // -------------------------------------------------------------------------- +function normalizeDockerLabelValue(raw: string | undefined): string | null { + const trimmed = raw?.trim() ?? ""; + if (!trimmed || trimmed === "<no value>") { + return null; + } + return trimmed; +} + +async function listSandboxBrowserContainers( + execDockerRawFn: ExecDockerRawFn, +): Promise<string[] | null> { + try { + const result = await execDockerRawFn( + ["ps", "-a", "--filter", "label=openclaw.sandboxBrowser=1", "--format", "{{.Names}}"], + { allowFailure: true }, + ); + if (result.code !== 0) { + return null; + } + return result.stdout + .toString("utf8") + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + } catch { + return null; + } +} + +async function readSandboxBrowserHashLabels(params: { + containerName: string; + execDockerRawFn: ExecDockerRawFn; +}): Promise<{ configHash: string | null; epoch: string | null } | null> { + try { + const result = await params.execDockerRawFn( + [ + "inspect", + "-f", + '{{ index .Config.Labels "openclaw.configHash" }}\t{{ index .Config.Labels "openclaw.browserConfigEpoch" }}', + params.containerName, + ], + { allowFailure: true }, + ); + if (result.code !== 0) { + return null; + } + const [hashRaw, epochRaw] = result.stdout.toString("utf8").split("\t"); + return { + configHash: normalizeDockerLabelValue(hashRaw), + epoch: normalizeDockerLabelValue(epochRaw), + }; + } catch { + return null; + } +} + +export async function collectSandboxBrowserHashLabelFindings(params?: { + execDockerRawFn?: ExecDockerRawFn; +}): Promise<SecurityAuditFinding[]> { + const findings: SecurityAuditFinding[] = []; + const execFn = params?.execDockerRawFn ?? execDockerRaw; + const containers = await listSandboxBrowserContainers(execFn); + if (!containers || containers.length === 0) { + return findings; + } + + const missingHash: string[] = []; + const staleEpoch: string[] = []; + + for (const containerName of containers) { + const labels = await readSandboxBrowserHashLabels({ containerName, execDockerRawFn: execFn }); + if (!labels) { + continue; + } + if (!labels.configHash) { + missingHash.push(containerName); + } + if (labels.epoch !== SANDBOX_BROWSER_SECURITY_HASH_EPOCH) { + staleEpoch.push(containerName); + } + } + + if (missingHash.length > 0) { + findings.push({ + checkId: "sandbox.browser_container.hash_label_missing", + severity: "warn", + title: "Sandbox browser container missing config hash label", + detail: + `Containers: ${missingHash.join(", ")}. ` + + "These browser containers predate hash-based drift checks and may miss security remediations until recreated.", + remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt).`, + }); + } + + if (staleEpoch.length > 0) { + findings.push({ + checkId: "sandbox.browser_container.hash_epoch_stale", + severity: "warn", + title: "Sandbox browser container hash epoch is stale", + detail: + `Containers: ${staleEpoch.join(", ")}. ` + + `Expected openclaw.browserConfigEpoch=${SANDBOX_BROWSER_SECURITY_HASH_EPOCH}.`, + remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt).`, + }); + } + + return findings; +} + export async function collectPluginsTrustFindings(params: { cfg: OpenClawConfig; stateDir: string;
src/security/audit-extra.ts+1 −0 modified@@ -27,6 +27,7 @@ export { // Async collectors export { + collectSandboxBrowserHashLabelFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, collectPluginsCodeSafetyFindings,
src/security/audit.test.ts+79 −0 modified@@ -419,6 +419,85 @@ describe("security audit", () => { ).toBe(true); }); + it("warns when sandbox browser containers have missing or stale hash labels", async () => { + const tmp = await makeTmpDir("browser-hash-labels"); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); + await fs.chmod(configPath, 0o600); + + const execDockerRawFn = (async (args: string[]) => { + if (args[0] === "ps") { + return { + stdout: Buffer.from("openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") { + return { + stdout: Buffer.from("abc123\tepoch-v0\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") { + return { + stdout: Buffer.from("<no value>\t<no value>\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable<SecurityAuditOptions["execDockerRawFn"]>; + + const res = await runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn, + }); + + expect(hasFinding(res, "sandbox.browser_container.hash_label_missing", "warn")).toBe(true); + expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale", "warn")).toBe(true); + const staleEpoch = res.findings.find( + (f) => f.checkId === "sandbox.browser_container.hash_epoch_stale", + ); + expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old"); + }); + + it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => { + const tmp = await makeTmpDir("browser-hash-labels-skip"); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); + await fs.chmod(configPath, 0o600); + + const execDockerRawFn = (async () => { + throw new Error("spawn docker ENOENT"); + }) as NonNullable<SecurityAuditOptions["execDockerRawFn"]>; + + const res = await runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn, + }); + + expect(hasFinding(res, "sandbox.browser_container.hash_label_missing")).toBe(false); + expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale")).toBe(false); + }); + it("uses symlink target permissions for config checks", async () => { if (isWindows) { return;
src/security/audit.ts+9 −0 modified@@ -1,4 +1,5 @@ import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; +import { execDockerRaw } from "../agents/sandbox/docker.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; @@ -18,6 +19,7 @@ import { collectHooksHardeningFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, + collectSandboxBrowserHashLabelFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, collectNodeDenyCommandPatternFindings, @@ -89,6 +91,8 @@ export type SecurityAuditOptions = { probeGatewayFn?: typeof probeGateway; /** Dependency injection for tests (Windows ACL checks). */ execIcacls?: ExecFn; + /** Dependency injection for tests (Docker label checks). */ + execDockerRawFn?: typeof execDockerRaw; }; function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { @@ -742,6 +746,11 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu findings.push( ...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir, platform, execIcacls })), ); + findings.push( + ...(await collectSandboxBrowserHashLabelFindings({ + execDockerRawFn: opts.execDockerRawFn, + })), + ); findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir }))); if (opts.deep === true) { findings.push(...(await collectPluginsCodeSafetyFindings({ stateDir })));
e7eba01efc4cSecurity: disable sandbox container --no-sandbox by default (#22451)
2 files changed · +9 −1
CHANGELOG.md+1 −0 modified@@ -111,6 +111,7 @@ Docs: https://docs.openclaw.ai - Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc. - Security/Gateway: parse `X-Forwarded-For` with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc. - iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable `nodes invoke` pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky. +- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and harden the default container security posture (`GHSA-43x4-g22p-3hrq`). Thanks @TerminalsandCoffee and @vincentkoc. ## 2026.2.19
scripts/sandbox-browser-entrypoint.sh+8 −1 modified@@ -11,6 +11,7 @@ VNC_PORT="${OPENCLAW_BROWSER_VNC_PORT:-${CLAWDBOT_BROWSER_VNC_PORT:-5900}}" NOVNC_PORT="${OPENCLAW_BROWSER_NOVNC_PORT:-${CLAWDBOT_BROWSER_NOVNC_PORT:-6080}}" ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:-1}}" HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}" +ALLOW_NO_SANDBOX="${OPENCLAW_BROWSER_NO_SANDBOX:-${CLAWDBOT_BROWSER_NO_SANDBOX:-0}}" mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}" @@ -43,9 +44,15 @@ CHROME_ARGS+=( "--disable-breakpad" "--disable-crash-reporter" "--metrics-recording-only" - "--no-sandbox" ) +if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then + CHROME_ARGS+=( + "--no-sandbox" + "--disable-setuid-sandbox" + ) +fi + chromium "${CHROME_ARGS[@]}" about:blank & for _ in $(seq 1 50); do
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
6- github.com/openclaw/openclaw/commit/1835dec2004fe7a62c6a7ba46b8485f124ec6199ghsapatchWEB
- github.com/openclaw/openclaw/commit/e7eba01efc4c3c400e9cfd3ce3d661cbc788a631ghsapatchWEB
- github.com/advisories/GHSA-43x4-g22p-3hrqghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-43x4-g22p-3hrqghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32046ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-os-level-sandbox-bypass-via-no-sandbox-flagghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.