fix(security): harden sandbox novnc observer flow

This commit is contained in:
Agent
2026-03-01 22:44:15 +00:00
parent 4ab13eca4d
commit 002539c01e
10 changed files with 128 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}$/);
});
});

View File

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

View File

@@ -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<string, NoVncObserverTokenEntry>();
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) {

View File

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

View File

@@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>OpenClaw noVNC Observer</title>
</head>
<body>
<p>Opening sandbox observer...</p>
<script>
const target = ${encodedTarget};
window.location.replace(target);
</script>
</body>
</html>`;
}
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<void>;
resolveSandboxNoVncToken?: (token: string) => string | null;
resolveSandboxNoVncToken?: (token: string) => ResolvedNoVncObserver | null;
}): Promise<BrowserBridge> {
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));
});
}