diff --git a/CHANGELOG.md b/CHANGELOG.md index a279505e665..104bd2a97e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai - CLI/Startup follow-up: add root `--help` fast-path bootstrap bypass with strict root-only matching, lazily resolve CLI channel options only when commands need them, merge build-time startup metadata (`dist/cli-startup-metadata.json`) with runtime catalog discovery so dynamic catalogs are preserved, and add low-power Linux doctor hints for compile-cache placement and respawn tuning. (#30975) Thanks @vincentkoc. - Telegram/Outbound API proxy env: keep the Node 22 `autoSelectFamily` global-dispatcher workaround while restoring env-proxy support by using `EnvHttpProxyAgent` so `HTTP_PROXY`/`HTTPS_PROXY` continue to apply to outbound requests. (#26207) Thanks @qsysbio-cjw for reporting and @rylena and @vincentkoc for work. - Browser/Security: fail closed on browser-control auth bootstrap errors; if auto-auth setup fails and no explicit token/password exists, browser control server startup now aborts instead of starting unauthenticated. This ships in the next npm release. Thanks @ijxpwastaken. +- Sandbox/noVNC hardening: increase observer password entropy, shorten observer token lifetime, and replace noVNC token redirect with a bootstrap page that keeps credentials out of `Location` query strings and adds strict no-cache/no-referrer headers. - Docs/Slack manifest scopes: add missing DM/group-DM bot scopes (`im:read`, `im:write`, `mpim:read`, `mpim:write`) to the Slack app manifest example so DM setup guidance is complete. (#29999) Thanks @JcMinarro. - Slack/Onboarding token help: update setup text to include the “From manifest” app-creation path and current install wording for obtaining the `xoxb-` bot token. (#30846) Thanks @yzhong52. - Slack/Bot attachment-only messages: when `allowBots: true`, bot messages with empty `text` now include non-forwarded attachment `text`/`fallback` content so webhook alerts are not silently dropped. (#27616) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index cc0c0cb529c..7da57445be4 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1149,7 +1149,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 OpenClaw emits a short-lived token URL (instead of exposing the password in the shared URL). +noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived token URL that serves a local bootstrap page; noVNC password is passed via URL fragment (instead of URL query). - `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser. - `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 8be57bd1064..fc3807b6658 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -25,7 +25,7 @@ and process access when the model does something dumb. - By default, sandbox browser containers use a dedicated Docker network (`openclaw-sandbox-browser`) instead of the global `bridge` network. Configure with `agents.defaults.sandbox.browser.network`. - Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress with a CIDR allowlist (for example `172.21.0.1/32`). - - noVNC observer access is password-protected by default; OpenClaw emits a short-lived token URL that resolves to the observer session. + - noVNC observer access is password-protected by default; OpenClaw emits a short-lived token URL that serves a local bootstrap page and opens noVNC with password in URL fragment (not query/header logs). - `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 42cefd4be01..80ca9441568 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -504,7 +504,7 @@ Notes: - No full desktop environment (GNOME) is needed; Xvfb provides the display. - Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`. - Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`). -- noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL instead of sharing the raw password in the URL. +- noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL that serves a local bootstrap page and keeps the password in URL fragment (instead of URL query). Use config: diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 4ad9d5f636b..2e83737ae57 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -162,7 +162,7 @@ describe("ensureSandboxBrowser create args", () => { const passwordEntry = envEntries.find((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="), ); - expect(passwordEntry).toMatch(/^OPENCLAW_BROWSER_NOVNC_PASSWORD=[a-f0-9]{8}$/); + expect(passwordEntry).toMatch(/^OPENCLAW_BROWSER_NOVNC_PASSWORD=[A-Za-z0-9]{8}$/); expect(result?.noVncUrl).toMatch(/^http:\/\/127\.0\.0\.1:19000\/sandbox\/novnc\?token=/); expect(result?.noVncUrl).not.toContain("password="); }); diff --git a/src/agents/sandbox/browser.novnc-url.test.ts b/src/agents/sandbox/browser.novnc-url.test.ts index 2020af869db..d7a6bb93d0c 100644 --- a/src/agents/sandbox/browser.novnc-url.test.ts +++ b/src/agents/sandbox/browser.novnc-url.test.ts @@ -2,45 +2,55 @@ import { describe, expect, it } from "vitest"; import { buildNoVncDirectUrl, buildNoVncObserverTokenUrl, + buildNoVncObserverTargetUrl, consumeNoVncObserverToken, + generateNoVncPassword, issueNoVncObserverToken, resetNoVncObserverTokensForTests, } from "./novnc-auth.js"; describe("noVNC auth helpers", () => { it("builds the default observer URL without password", () => { - expect(buildNoVncDirectUrl(45678)).toBe( - "http://127.0.0.1:45678/vnc.html?autoconnect=1&resize=remote", - ); + expect(buildNoVncDirectUrl(45678)).toBe("http://127.0.0.1:45678/vnc.html"); }); - it("adds an encoded password query parameter when provided", () => { - expect(buildNoVncDirectUrl(45678, "a+b c&d")).toBe( - "http://127.0.0.1:45678/vnc.html?autoconnect=1&resize=remote&password=a%2Bb+c%26d", + it("builds a fragment-based observer target URL with password", () => { + expect(buildNoVncObserverTargetUrl({ port: 45678, password: "a+b c&d" })).toBe( + "http://127.0.0.1:45678/vnc.html#autoconnect=1&resize=remote&password=a%2Bb+c%26d", ); }); it("issues one-time short-lived observer tokens", () => { resetNoVncObserverTokensForTests(); const token = issueNoVncObserverToken({ - url: "http://127.0.0.1:50123/vnc.html?autoconnect=1&resize=remote&password=abcd1234", + noVncPort: 50123, + password: "abcd1234", nowMs: 1000, ttlMs: 100, }); expect(buildNoVncObserverTokenUrl("http://127.0.0.1:19999", token)).toBe( `http://127.0.0.1:19999/sandbox/novnc?token=${token}`, ); - expect(consumeNoVncObserverToken(token, 1050)).toContain("/vnc.html?"); + expect(consumeNoVncObserverToken(token, 1050)).toEqual({ + noVncPort: 50123, + password: "abcd1234", + }); expect(consumeNoVncObserverToken(token, 1050)).toBeNull(); }); it("expires observer tokens", () => { resetNoVncObserverTokensForTests(); const token = issueNoVncObserverToken({ - url: "http://127.0.0.1:50123/vnc.html?autoconnect=1&resize=remote&password=abcd1234", + noVncPort: 50123, + password: "abcd1234", nowMs: 1000, ttlMs: 100, }); expect(consumeNoVncObserverToken(token, 1200)).toBeNull(); }); + + it("generates 8-char alphanumeric passwords", () => { + const password = generateNoVncPassword(); + expect(password).toMatch(/^[a-zA-Z0-9]{8}$/); + }); }); diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 3589c1e9c09..a58348fcb33 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -24,7 +24,6 @@ import { readDockerPort, } from "./docker.js"; import { - buildNoVncDirectUrl, buildNoVncObserverTokenUrl, consumeNoVncObserverToken, generateNoVncPassword, @@ -390,8 +389,10 @@ export async function ensureSandboxBrowser(params: { const noVncUrl = mappedNoVnc && noVncEnabled ? (() => { - const directUrl = buildNoVncDirectUrl(mappedNoVnc, noVncPassword); - const token = issueNoVncObserverToken({ url: directUrl }); + const token = issueNoVncObserverToken({ + noVncPort: mappedNoVnc, + password: noVncPassword, + }); return buildNoVncObserverTokenUrl(resolvedBridge.baseUrl, token); })() : undefined; diff --git a/src/agents/sandbox/novnc-auth.ts b/src/agents/sandbox/novnc-auth.ts index b176479c111..ef1e78334b0 100644 --- a/src/agents/sandbox/novnc-auth.ts +++ b/src/agents/sandbox/novnc-auth.ts @@ -1,13 +1,21 @@ import crypto from "node:crypto"; export const NOVNC_PASSWORD_ENV_KEY = "OPENCLAW_BROWSER_NOVNC_PASSWORD"; -const NOVNC_TOKEN_TTL_MS = 5 * 60 * 1000; +const NOVNC_TOKEN_TTL_MS = 60 * 1000; +const NOVNC_PASSWORD_LENGTH = 8; +const NOVNC_PASSWORD_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; type NoVncObserverTokenEntry = { - url: string; + noVncPort: number; + password?: string; expiresAt: number; }; +export type NoVncObserverTokenPayload = { + noVncPort: number; + password?: string; +}; + const NO_VNC_OBSERVER_TOKENS = new Map(); function pruneExpiredNoVncObserverTokens(now: number) { @@ -24,22 +32,31 @@ export function isNoVncEnabled(params: { enableNoVnc: boolean; headless: boolean export function generateNoVncPassword() { // VNC auth uses an 8-char password max. - return crypto.randomBytes(4).toString("hex"); + let out = ""; + for (let i = 0; i < NOVNC_PASSWORD_LENGTH; i += 1) { + out += NOVNC_PASSWORD_ALPHABET[crypto.randomInt(0, NOVNC_PASSWORD_ALPHABET.length)]; + } + return out; } -export function buildNoVncDirectUrl(port: number, password?: string) { +export function buildNoVncDirectUrl(port: number) { + return `http://127.0.0.1:${port}/vnc.html`; +} + +export function buildNoVncObserverTargetUrl(params: { port: number; password?: string }) { const query = new URLSearchParams({ autoconnect: "1", resize: "remote", }); - if (password?.trim()) { - query.set("password", password); + if (params.password?.trim()) { + query.set("password", params.password); } - return `http://127.0.0.1:${port}/vnc.html?${query.toString()}`; + return `${buildNoVncDirectUrl(params.port)}#${query.toString()}`; } export function issueNoVncObserverToken(params: { - url: string; + noVncPort: number; + password?: string; ttlMs?: number; nowMs?: number; }): string { @@ -47,13 +64,17 @@ export function issueNoVncObserverToken(params: { pruneExpiredNoVncObserverTokens(now); const token = crypto.randomBytes(24).toString("hex"); NO_VNC_OBSERVER_TOKENS.set(token, { - url: params.url, + noVncPort: params.noVncPort, + password: params.password?.trim() || undefined, expiresAt: now + Math.max(1, params.ttlMs ?? NOVNC_TOKEN_TTL_MS), }); return token; } -export function consumeNoVncObserverToken(token: string, nowMs?: number): string | null { +export function consumeNoVncObserverToken( + token: string, + nowMs?: number, +): NoVncObserverTokenPayload | null { const now = nowMs ?? Date.now(); pruneExpiredNoVncObserverTokens(now); const normalized = token.trim(); @@ -68,7 +89,7 @@ export function consumeNoVncObserverToken(token: string, nowMs?: number): string if (entry.expiresAt <= now) { return null; } - return entry.url; + return { noVncPort: entry.noVncPort, password: entry.password }; } export function buildNoVncObserverTokenUrl(baseUrl: string, token: string) { diff --git a/src/browser/bridge-server.auth.test.ts b/src/browser/bridge-server.auth.test.ts index 685f43b060a..eb72c340ae3 100644 --- a/src/browser/bridge-server.auth.test.ts +++ b/src/browser/bridge-server.auth.test.ts @@ -79,4 +79,31 @@ describe("startBrowserBridgeServer auth", () => { }), ).rejects.toThrow(/requires auth/i); }); + + it("serves noVNC bootstrap html without leaking password in Location header", async () => { + const bridge = await startBrowserBridgeServer({ + resolved: buildResolvedConfig(), + authToken: "secret-token", + resolveSandboxNoVncToken: (token) => { + if (token !== "valid-token") { + return null; + } + return { noVncPort: 45678, password: "Abc123xy" }; + }, + }); + servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); + + const res = await fetch(`${bridge.baseUrl}/sandbox/novnc?token=valid-token`); + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + expect(res.headers.get("cache-control")).toContain("no-store"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + + const body = await res.text(); + expect(body).toContain("window.location.replace"); + expect(body).toContain( + "http://127.0.0.1:45678/vnc.html#autoconnect=1&resize=remote&password=Abc123xy", + ); + expect(body).not.toContain("?password="); + }); }); diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts index 1640f9642f1..c1d0c082201 100644 --- a/src/browser/bridge-server.ts +++ b/src/browser/bridge-server.ts @@ -23,6 +23,39 @@ export type BrowserBridge = { state: BrowserServerState; }; +type ResolvedNoVncObserver = { + noVncPort: number; + password?: string; +}; + +function buildNoVncBootstrapHtml(params: ResolvedNoVncObserver): string { + const hash = new URLSearchParams({ + autoconnect: "1", + resize: "remote", + }); + if (params.password?.trim()) { + hash.set("password", params.password); + } + const targetUrl = `http://127.0.0.1:${params.noVncPort}/vnc.html#${hash.toString()}`; + const encodedTarget = JSON.stringify(targetUrl); + return ` + + + + + + OpenClaw noVNC Observer + + +

Opening sandbox observer...

+ + +`; +} + export async function startBrowserBridgeServer(params: { resolved: ResolvedBrowserConfig; host?: string; @@ -30,7 +63,7 @@ export async function startBrowserBridgeServer(params: { authToken?: string; authPassword?: string; onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise; - resolveSandboxNoVncToken?: (token: string) => string | null; + resolveSandboxNoVncToken?: (token: string) => ResolvedNoVncObserver | null; }): Promise { const host = params.host ?? "127.0.0.1"; if (!isLoopbackHost(host)) { @@ -43,18 +76,21 @@ export async function startBrowserBridgeServer(params: { if (params.resolveSandboxNoVncToken) { app.get("/sandbox/novnc", (req, res) => { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + res.setHeader("Referrer-Policy", "no-referrer"); const rawToken = typeof req.query?.token === "string" ? req.query.token.trim() : ""; if (!rawToken) { res.status(400).send("Missing token"); return; } - const redirectUrl = params.resolveSandboxNoVncToken?.(rawToken); - if (!redirectUrl) { + const resolved = params.resolveSandboxNoVncToken?.(rawToken); + if (!resolved) { res.status(404).send("Invalid or expired token"); return; } - res.setHeader("Cache-Control", "no-store"); - res.redirect(302, redirectUrl); + res.type("html").status(200).send(buildNoVncBootstrapHtml(resolved)); }); }