diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd632e4c..9d9247fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/security.md b/docs/cli/security.md index 129f004c7..1e97225c8 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -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). diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 487cd3e29..069eea617 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -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 = diff --git a/src/agents/sandbox/config-hash.test.ts b/src/agents/sandbox/config-hash.test.ts index be70a0470..852bfea1d 100644 --- a/src/agents/sandbox/config-hash.test.ts +++ b/src/agents/sandbox/config-hash.test.ts @@ -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); + }); }); diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index 62dfd9142..e77652aab 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -14,6 +14,7 @@ type SandboxBrowserHashInput = { SandboxBrowserConfig, "cdpPort" | "vncPort" | "noVncPort" | "headless" | "enableNoVnc" >; + securityEpoch: string; workspaceAccess: SandboxWorkspaceAccess; workspaceDir: string; agentWorkspaceDir: string; diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 3076dac5d..6e3c4f776 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -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; diff --git a/src/discord/voice/manager.ts b/src/discord/voice/manager.ts index 8668e57b6..f1e7585d6 100644 --- a/src/discord/voice/manager.ts +++ b/src/discord/voice/manager.ts @@ -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) { diff --git a/src/gateway/http-auth-helpers.test.ts b/src/gateway/http-auth-helpers.test.ts index 0a4cab7d4..22ceb975d 100644 --- a/src/gateway/http-auth-helpers.test.ts +++ b/src/gateway/http-auth-helpers.test.ts @@ -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", diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 2b61bf1e9..ef83ab9f4 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -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; + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- @@ -242,6 +250,115 @@ async function readInstalledPackageVersion(dir: string): Promise") { + return null; + } + return trimmed; +} + +async function listSandboxBrowserContainers( + execDockerRawFn: ExecDockerRawFn, +): Promise { + 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 { + 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; diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 4e101ba65..d38e753ca 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -27,6 +27,7 @@ export { // Async collectors export { + collectSandboxBrowserHashLabelFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, collectPluginsCodeSafetyFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 8e6a04725..bcef4baf9 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -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("\t\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable; + + 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; + + 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; diff --git a/src/security/audit.ts b/src/security/audit.ts index 8d4b3427f..92bf54f49 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -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