From 621d8e1312482f122f18c43c72c67211b141da01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 13:44:17 +0100 Subject: [PATCH] fix(sandbox): require noVNC observer password auth --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 1 + docs/gateway/sandboxing.md | 1 + docs/install/docker.md | 1 + scripts/sandbox-browser-entrypoint.sh | 13 +++++- src/agents/sandbox/browser.novnc-url.test.ts | 16 +++++++ src/agents/sandbox/browser.ts | 47 ++++++++++++++++---- src/agents/sandbox/constants.ts | 2 +- src/agents/sandbox/docker.ts | 19 ++++++++ 9 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 src/agents/sandbox/browser.novnc-url.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 612394ce912..d975feb0abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,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. - 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. +- Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit password-bearing auto-connect observer URLs while keeping loopback-only host port publishing. This ships in the next npm release. Thanks @TerminalsandCoffee for reporting. - 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/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b3bc9b77b61..860d7a2e33b 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -992,6 +992,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway **`docker.binds`** mounts additional host directories; global and per-agent binds are merged. **Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in main config. +noVNC observer access uses VNC auth by default and the generated URL includes the password query parameter automatically. - `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser. - `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index fe27d2c51ad..d07b85617fd 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -22,6 +22,7 @@ and process access when the model does something dumb. - Optional sandboxed browser (`agents.defaults.sandbox.browser`). - By default, the sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it. Configure via `agents.defaults.sandbox.browser.autoStart` and `agents.defaults.sandbox.browser.autoStartTimeoutMs`. + - noVNC observer access is password-protected by default; OpenClaw emits an auto-connect URL with password query parameter. - `agents.defaults.sandbox.browser.allowHostControl` lets sandboxed sessions target the host browser explicitly. - Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`. diff --git a/docs/install/docker.md b/docs/install/docker.md index 9cba10bf7d7..37e44fd1e65 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -495,6 +495,7 @@ Notes: - Headful (Xvfb) reduces bot blocking vs headless. - Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`. - No full desktop environment (GNOME) is needed; Xvfb provides the display. +- noVNC observer access is password-protected by default; OpenClaw provides an auto-connect URL with the password query parameter. Use config: diff --git a/scripts/sandbox-browser-entrypoint.sh b/scripts/sandbox-browser-entrypoint.sh index 382090ccaee..ce74d44f5c4 100755 --- a/scripts/sandbox-browser-entrypoint.sh +++ b/scripts/sandbox-browser-entrypoint.sh @@ -12,6 +12,7 @@ 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}}" +NOVNC_PASSWORD="${OPENCLAW_BROWSER_NOVNC_PASSWORD:-${CLAWDBOT_BROWSER_NOVNC_PASSWORD:-}}" mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}" @@ -67,7 +68,17 @@ socat \ TCP:127.0.0.1:"${CHROME_CDP_PORT}" & if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then - x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost & + # VNC auth passwords are max 8 chars; use a random default when not provided. + if [[ -z "${NOVNC_PASSWORD}" ]]; then + NOVNC_PASSWORD="$(< /proc/sys/kernel/random/uuid)" + NOVNC_PASSWORD="${NOVNC_PASSWORD//-/}" + NOVNC_PASSWORD="${NOVNC_PASSWORD:0:8}" + fi + NOVNC_PASSWD_FILE="${HOME}/.vnc/passwd" + mkdir -p "${HOME}/.vnc" + x11vnc -storepasswd "${NOVNC_PASSWORD}" "${NOVNC_PASSWD_FILE}" >/dev/null + chmod 600 "${NOVNC_PASSWD_FILE}" + x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -rfbauth "${NOVNC_PASSWD_FILE}" -localhost & websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" & fi diff --git a/src/agents/sandbox/browser.novnc-url.test.ts b/src/agents/sandbox/browser.novnc-url.test.ts new file mode 100644 index 00000000000..b542f6b9496 --- /dev/null +++ b/src/agents/sandbox/browser.novnc-url.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { buildNoVncObserverUrl } from "./browser.js"; + +describe("buildNoVncObserverUrl", () => { + it("builds the default observer URL without password", () => { + expect(buildNoVncObserverUrl(45678)).toBe( + "http://127.0.0.1:45678/vnc.html?autoconnect=1&resize=remote", + ); + }); + + it("adds an encoded password query parameter when provided", () => { + expect(buildNoVncObserverUrl(45678, "a+b c&d")).toBe( + "http://127.0.0.1:45678/vnc.html?autoconnect=1&resize=remote&password=a%2Bb+c%26d", + ); + }); +}); diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 069eea6179f..125e9bbdf4a 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -19,6 +19,7 @@ import { buildSandboxCreateArgs, dockerContainerState, execDocker, + readDockerContainerEnvVar, readDockerContainerLabel, readDockerPort, } from "./docker.js"; @@ -28,6 +29,23 @@ import { isToolAllowed } from "./tool-policy.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000; +const NOVNC_PASSWORD_ENV_KEY = "OPENCLAW_BROWSER_NOVNC_PASSWORD"; + +function generateNoVncPassword() { + // VNC auth uses an 8-char password max. + return crypto.randomBytes(4).toString("hex"); +} + +export function buildNoVncObserverUrl(port: number, password?: string) { + const query = new URLSearchParams({ + autoconnect: "1", + resize: "remote", + }); + if (password?.trim()) { + query.set("password", password); + } + return `http://127.0.0.1:${port}/vnc.html?${query.toString()}`; +} async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }): Promise { const deadline = Date.now() + Math.max(0, params.timeoutMs); @@ -140,8 +158,14 @@ export async function ensureSandboxBrowser(params: { let running = state.running; let currentHash: string | null = null; let hashMismatch = false; + const noVncEnabled = params.cfg.browser.enableNoVnc && !params.cfg.browser.headless; + let noVncPassword: string | undefined; if (hasContainer) { + if (noVncEnabled) { + noVncPassword = + (await readDockerContainerEnvVar(containerName, NOVNC_PASSWORD_ENV_KEY)) ?? undefined; + } const registry = await readBrowserRegistry(); const registryEntry = registry.entries.find((entry) => entry.containerName === containerName); currentHash = await readDockerContainerLabel(containerName, "openclaw.configHash"); @@ -177,6 +201,9 @@ export async function ensureSandboxBrowser(params: { } if (!hasContainer) { + if (noVncEnabled) { + noVncPassword = generateNoVncPassword(); + } await ensureSandboxBrowserImage(browserImage); const args = buildSandboxCreateArgs({ name: containerName, @@ -201,7 +228,7 @@ export async function ensureSandboxBrowser(params: { ); } args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`); - if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) { + if (noVncEnabled) { args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); } args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); @@ -209,6 +236,9 @@ export async function ensureSandboxBrowser(params: { args.push("-e", `OPENCLAW_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`); args.push("-e", `OPENCLAW_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`); + if (noVncEnabled && noVncPassword) { + args.push("-e", `${NOVNC_PASSWORD_ENV_KEY}=${noVncPassword}`); + } args.push(browserImage); await execDocker(args); await execDocker(["start", containerName]); @@ -221,10 +251,13 @@ export async function ensureSandboxBrowser(params: { throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`); } - const mappedNoVnc = - params.cfg.browser.enableNoVnc && !params.cfg.browser.headless - ? await readDockerPort(containerName, params.cfg.browser.noVncPort) - : null; + const mappedNoVnc = noVncEnabled + ? await readDockerPort(containerName, params.cfg.browser.noVncPort) + : null; + if (noVncEnabled && !noVncPassword) { + noVncPassword = + (await readDockerContainerEnvVar(containerName, NOVNC_PASSWORD_ENV_KEY)) ?? undefined; + } const existing = BROWSER_BRIDGES.get(params.scopeKey); const existingProfile = existing @@ -323,9 +356,7 @@ export async function ensureSandboxBrowser(params: { }); const noVncUrl = - mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless - ? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` - : undefined; + mappedNoVnc && noVncEnabled ? buildNoVncObserverUrl(mappedNoVnc, noVncPassword) : undefined; return { bridgeUrl: resolvedBridge.baseUrl, diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 6e3c4f77695..f4172b2e0dd 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -38,7 +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 SANDBOX_BROWSER_SECURITY_HASH_EPOCH = "2026-02-21-novnc-auth-default"; export const DEFAULT_SANDBOX_BROWSER_PREFIX = "openclaw-sbx-browser-"; export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 522066632d9..a03a5c26da6 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -145,6 +145,25 @@ export async function readDockerContainerLabel( return raw; } +export async function readDockerContainerEnvVar( + containerName: string, + envVar: string, +): Promise { + const result = await execDocker( + ["inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", containerName], + { allowFailure: true }, + ); + if (result.code !== 0) { + return null; + } + for (const line of result.stdout.split(/\r?\n/)) { + if (line.startsWith(`${envVar}=`)) { + return line.slice(envVar.length + 1); + } + } + return null; +} + export async function readDockerPort(containerName: string, port: number) { const result = await execDocker(["port", containerName, `${port}/tcp`], { allowFailure: true,