fix(sandbox): require noVNC observer password auth

This commit is contained in:
Peter Steinberger 2026-02-21 13:44:17 +01:00
parent 6cb7e16d40
commit 621d8e1312
9 changed files with 91 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<boolean> {
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,

View File

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

View File

@ -145,6 +145,25 @@ export async function readDockerContainerLabel(
return raw;
}
export async function readDockerContainerEnvVar(
containerName: string,
envVar: string,
): Promise<string | null> {
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,