mirror of https://github.com/openclaw/openclaw.git
fix(sandbox): require noVNC observer password auth
This commit is contained in:
parent
6cb7e16d40
commit
621d8e1312
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue