fix(security): force sandbox browser hash migration and audit stale labels
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ type SandboxBrowserHashInput = {
|
||||
SandboxBrowserConfig,
|
||||
"cdpPort" | "vncPort" | "noVncPort" | "headless" | "enableNoVnc"
|
||||
>;
|
||||
securityEpoch: string;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
|
||||
// Async collectors
|
||||
export {
|
||||
collectSandboxBrowserHashLabelFindings,
|
||||
collectIncludeFilePermFindings,
|
||||
collectInstalledSkillsCodeSafetyFindings,
|
||||
collectPluginsCodeSafetyFindings,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })));
|
||||
|
||||
Reference in New Issue
Block a user