fix(security): force sandbox browser hash migration and audit stale labels

This commit is contained in:
Peter Steinberger
2026-02-21 13:25:35 +01:00
parent b2d84528f8
commit 1835dec200
12 changed files with 254 additions and 6 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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 =

View File

@@ -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);
});
});

View File

@@ -14,6 +14,7 @@ type SandboxBrowserHashInput = {
SandboxBrowserConfig,
"cdpPort" | "vncPort" | "noVncPort" | "headless" | "enableNoVnc"
>;
securityEpoch: string;
workspaceAccess: SandboxWorkspaceAccess;
workspaceDir: string;
agentWorkspaceDir: string;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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;

View File

@@ -27,6 +27,7 @@ export {
// Async collectors
export {
collectSandboxBrowserHashLabelFindings,
collectIncludeFilePermFindings,
collectInstalledSkillsCodeSafetyFindings,
collectPluginsCodeSafetyFindings,

View File

@@ -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;

View File

@@ -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 })));