mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 12:18:37 +00:00
fix(sandbox): require noVNC observer password auth
This commit is contained in:
@@ -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/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/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: 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.
|
- 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.
|
- 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.
|
- 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.
|
**`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.
|
**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.
|
- `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.
|
- `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`).
|
- Optional sandboxed browser (`agents.defaults.sandbox.browser`).
|
||||||
- By default, the sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it.
|
- 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`.
|
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.
|
- `agents.defaults.sandbox.browser.allowHostControl` lets sandboxed sessions target the host browser explicitly.
|
||||||
- Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`.
|
- Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`.
|
||||||
|
|
||||||
|
|||||||
@@ -495,6 +495,7 @@ Notes:
|
|||||||
- Headful (Xvfb) reduces bot blocking vs headless.
|
- Headful (Xvfb) reduces bot blocking vs headless.
|
||||||
- Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`.
|
- Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`.
|
||||||
- No full desktop environment (GNOME) is needed; Xvfb provides the display.
|
- 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:
|
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}}"
|
ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:-1}}"
|
||||||
HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}"
|
HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}"
|
||||||
ALLOW_NO_SANDBOX="${OPENCLAW_BROWSER_NO_SANDBOX:-${CLAWDBOT_BROWSER_NO_SANDBOX:-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}"
|
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
|
||||||
|
|
||||||
@@ -67,7 +68,17 @@ socat \
|
|||||||
TCP:127.0.0.1:"${CHROME_CDP_PORT}" &
|
TCP:127.0.0.1:"${CHROME_CDP_PORT}" &
|
||||||
|
|
||||||
if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then
|
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}" &
|
websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
16
src/agents/sandbox/browser.novnc-url.test.ts
Normal file
16
src/agents/sandbox/browser.novnc-url.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
buildSandboxCreateArgs,
|
buildSandboxCreateArgs,
|
||||||
dockerContainerState,
|
dockerContainerState,
|
||||||
execDocker,
|
execDocker,
|
||||||
|
readDockerContainerEnvVar,
|
||||||
readDockerContainerLabel,
|
readDockerContainerLabel,
|
||||||
readDockerPort,
|
readDockerPort,
|
||||||
} from "./docker.js";
|
} from "./docker.js";
|
||||||
@@ -28,6 +29,23 @@ import { isToolAllowed } from "./tool-policy.js";
|
|||||||
import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
|
import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
|
||||||
|
|
||||||
const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000;
|
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> {
|
async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }): Promise<boolean> {
|
||||||
const deadline = Date.now() + Math.max(0, params.timeoutMs);
|
const deadline = Date.now() + Math.max(0, params.timeoutMs);
|
||||||
@@ -140,8 +158,14 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
let running = state.running;
|
let running = state.running;
|
||||||
let currentHash: string | null = null;
|
let currentHash: string | null = null;
|
||||||
let hashMismatch = false;
|
let hashMismatch = false;
|
||||||
|
const noVncEnabled = params.cfg.browser.enableNoVnc && !params.cfg.browser.headless;
|
||||||
|
let noVncPassword: string | undefined;
|
||||||
|
|
||||||
if (hasContainer) {
|
if (hasContainer) {
|
||||||
|
if (noVncEnabled) {
|
||||||
|
noVncPassword =
|
||||||
|
(await readDockerContainerEnvVar(containerName, NOVNC_PASSWORD_ENV_KEY)) ?? undefined;
|
||||||
|
}
|
||||||
const registry = await readBrowserRegistry();
|
const registry = await readBrowserRegistry();
|
||||||
const registryEntry = registry.entries.find((entry) => entry.containerName === containerName);
|
const registryEntry = registry.entries.find((entry) => entry.containerName === containerName);
|
||||||
currentHash = await readDockerContainerLabel(containerName, "openclaw.configHash");
|
currentHash = await readDockerContainerLabel(containerName, "openclaw.configHash");
|
||||||
@@ -177,6 +201,9 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasContainer) {
|
if (!hasContainer) {
|
||||||
|
if (noVncEnabled) {
|
||||||
|
noVncPassword = generateNoVncPassword();
|
||||||
|
}
|
||||||
await ensureSandboxBrowserImage(browserImage);
|
await ensureSandboxBrowserImage(browserImage);
|
||||||
const args = buildSandboxCreateArgs({
|
const args = buildSandboxCreateArgs({
|
||||||
name: containerName,
|
name: containerName,
|
||||||
@@ -201,7 +228,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
|
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("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
|
||||||
}
|
}
|
||||||
args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`);
|
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_CDP_PORT=${params.cfg.browser.cdpPort}`);
|
||||||
args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`);
|
args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`);
|
||||||
args.push("-e", `OPENCLAW_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`);
|
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);
|
args.push(browserImage);
|
||||||
await execDocker(args);
|
await execDocker(args);
|
||||||
await execDocker(["start", containerName]);
|
await execDocker(["start", containerName]);
|
||||||
@@ -221,10 +251,13 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`);
|
throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedNoVnc =
|
const mappedNoVnc = noVncEnabled
|
||||||
params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
|
? await readDockerPort(containerName, params.cfg.browser.noVncPort)
|
||||||
? await readDockerPort(containerName, params.cfg.browser.noVncPort)
|
: null;
|
||||||
: null;
|
if (noVncEnabled && !noVncPassword) {
|
||||||
|
noVncPassword =
|
||||||
|
(await readDockerContainerEnvVar(containerName, NOVNC_PASSWORD_ENV_KEY)) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = BROWSER_BRIDGES.get(params.scopeKey);
|
const existing = BROWSER_BRIDGES.get(params.scopeKey);
|
||||||
const existingProfile = existing
|
const existingProfile = existing
|
||||||
@@ -323,9 +356,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const noVncUrl =
|
const noVncUrl =
|
||||||
mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
|
mappedNoVnc && noVncEnabled ? buildNoVncObserverUrl(mappedNoVnc, noVncPassword) : undefined;
|
||||||
? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bridgeUrl: resolvedBridge.baseUrl,
|
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_BROWSER_IMAGE = "openclaw-sandbox-browser:bookworm-slim";
|
||||||
export const DEFAULT_SANDBOX_COMMON_IMAGE = "openclaw-sandbox-common: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_PREFIX = "openclaw-sbx-browser-";
|
||||||
export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
|
export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
|
||||||
|
|||||||
@@ -145,6 +145,25 @@ export async function readDockerContainerLabel(
|
|||||||
return raw;
|
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) {
|
export async function readDockerPort(containerName: string, port: number) {
|
||||||
const result = await execDocker(["port", containerName, `${port}/tcp`], {
|
const result = await execDocker(["port", containerName, `${port}/tcp`], {
|
||||||
allowFailure: true,
|
allowFailure: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user