diff --git a/CHANGELOG.md b/CHANGELOG.md index acc8a7878..c3e628cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. - Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS. - Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths. +- Security/Browser: require auth for the sandbox browser bridge server (protects `/profiles`, `/tabs`, CDP URLs, and other control endpoints). Thanks @jackhax. - Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane. - Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane. - Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow. diff --git a/src/agents/sandbox/browser-bridges.ts b/src/agents/sandbox/browser-bridges.ts index aceb713f9..5a6e3db99 100644 --- a/src/agents/sandbox/browser-bridges.ts +++ b/src/agents/sandbox/browser-bridges.ts @@ -1,3 +1,11 @@ import type { BrowserBridge } from "../../browser/bridge-server.js"; -export const BROWSER_BRIDGES = new Map(); +export const BROWSER_BRIDGES = new Map< + string, + { + bridge: BrowserBridge; + containerName: string; + authToken?: string; + authPassword?: string; + } +>(); diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index f4b268fb1..b1aa01451 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -90,6 +90,7 @@ export async function ensureSandboxBrowser(params: { agentWorkspaceDir: string; cfg: SandboxConfig; evaluateEnabled?: boolean; + bridgeAuth?: { token?: string; password?: string }; }): Promise { if (!params.cfg.browser.enabled) { return null; @@ -148,19 +149,29 @@ export async function ensureSandboxBrowser(params: { ? await readDockerPort(containerName, params.cfg.browser.noVncPort) : null; + const desiredAuthToken = params.bridgeAuth?.token?.trim() || undefined; + const desiredAuthPassword = params.bridgeAuth?.password?.trim() || undefined; + const existing = BROWSER_BRIDGES.get(params.scopeKey); const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) : null; const shouldReuse = existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; + const authMatches = + !existing || + (existing.authToken === desiredAuthToken && existing.authPassword === desiredAuthPassword); if (existing && !shouldReuse) { await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); BROWSER_BRIDGES.delete(params.scopeKey); } + if (existing && shouldReuse && !authMatches) { + await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); + BROWSER_BRIDGES.delete(params.scopeKey); + } const bridge = (() => { - if (shouldReuse && existing) { + if (shouldReuse && authMatches && existing) { return existing.bridge; } return null; @@ -196,15 +207,19 @@ export async function ensureSandboxBrowser(params: { headless: params.cfg.browser.headless, evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, }), + authToken: desiredAuthToken, + authPassword: desiredAuthPassword, onEnsureAttachTarget, }); }; const resolvedBridge = await ensureBridge(); - if (!shouldReuse) { + if (!shouldReuse || !authMatches) { BROWSER_BRIDGES.set(params.scopeKey, { bridge: resolvedBridge, containerName, + authToken: desiredAuthToken, + authPassword: desiredAuthPassword, }); } diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index b82c3bcc8..784fe7bcc 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -2,6 +2,8 @@ import fs from "node:fs/promises"; import type { OpenClawConfig } from "../../config/config.js"; import type { SandboxContext, SandboxWorkspaceInfo } from "./types.js"; import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../browser/constants.js"; +import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "../../browser/control-auth.js"; +import { loadConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath } from "../../utils.js"; import { syncSkillsToWorkspace } from "../skills.js"; @@ -76,12 +78,30 @@ export async function resolveSandboxContext(params: { const evaluateEnabled = params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; + + const bridgeAuth = cfg.browser.enabled + ? await (async () => { + // Sandbox browser bridge server runs on a loopback TCP port; always wire up + // the same auth that loopback browser clients will send (token/password). + const cfgForAuth = params.config ?? loadConfig(); + let browserAuth = resolveBrowserControlAuth(cfgForAuth); + try { + const ensured = await ensureBrowserControlAuth({ cfg: cfgForAuth }); + browserAuth = ensured.auth; + } catch (error) { + const message = error instanceof Error ? error.message : JSON.stringify(error); + defaultRuntime.error?.(`Sandbox browser auth ensure failed: ${message}`); + } + return browserAuth; + })() + : undefined; const browser = await ensureSandboxBrowser({ scopeKey, workspaceDir, agentWorkspaceDir, cfg, evaluateEnabled, + bridgeAuth, }); const sandboxContext: SandboxContext = { diff --git a/src/browser/bridge-server.auth.test.ts b/src/browser/bridge-server.auth.test.ts new file mode 100644 index 000000000..de0ef6366 --- /dev/null +++ b/src/browser/bridge-server.auth.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startBrowserBridgeServer, stopBrowserBridgeServer } from "./bridge-server.js"; +import { + DEFAULT_OPENCLAW_BROWSER_COLOR, + DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, +} from "./constants.js"; + +function buildResolvedConfig() { + return { + enabled: true, + evaluateEnabled: false, + controlPort: 0, + cdpProtocol: "http", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + remoteCdpTimeoutMs: 1500, + remoteCdpHandshakeTimeoutMs: 3000, + color: DEFAULT_OPENCLAW_BROWSER_COLOR, + executablePath: undefined, + headless: true, + noSandbox: false, + attachOnly: true, + defaultProfile: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, + profiles: { + [DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: { + cdpPort: 1, + color: DEFAULT_OPENCLAW_BROWSER_COLOR, + }, + }, + } as const; +} + +describe("startBrowserBridgeServer auth", () => { + const servers: Array<{ stop: () => Promise }> = []; + + afterEach(async () => { + while (servers.length) { + const s = servers.pop(); + if (s) { + await s.stop(); + } + } + }); + + it("rejects unauthenticated requests when authToken is set", async () => { + const bridge = await startBrowserBridgeServer({ + resolved: buildResolvedConfig(), + authToken: "secret-token", + }); + servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); + + const unauth = await fetch(`${bridge.baseUrl}/`); + expect(unauth.status).toBe(401); + + const authed = await fetch(`${bridge.baseUrl}/`, { + headers: { Authorization: "Bearer secret-token" }, + }); + expect(authed.status).toBe(200); + }); + + it("accepts x-openclaw-password when authPassword is set", async () => { + const bridge = await startBrowserBridgeServer({ + resolved: buildResolvedConfig(), + authPassword: "secret-password", + }); + servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); + + const unauth = await fetch(`${bridge.baseUrl}/`); + expect(unauth.status).toBe(401); + + const authed = await fetch(`${bridge.baseUrl}/`, { + headers: { "x-openclaw-password": "secret-password" }, + }); + expect(authed.status).toBe(200); + }); +}); diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts index a1802493f..cb69d510f 100644 --- a/src/browser/bridge-server.ts +++ b/src/browser/bridge-server.ts @@ -1,8 +1,10 @@ import type { Server } from "node:http"; +import type { IncomingMessage } from "node:http"; import type { AddressInfo } from "node:net"; import express from "express"; import type { ResolvedBrowserConfig } from "./config.js"; import type { BrowserRouteRegistrar } from "./routes/types.js"; +import { safeEqualSecret } from "../security/secret-equal.js"; import { registerBrowserRoutes } from "./routes/index.js"; import { type BrowserServerState, @@ -10,6 +12,67 @@ import { type ProfileContext, } from "./server-context.js"; +function firstHeaderValue(value: string | string[] | undefined): string { + return Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); +} + +function parseBearerToken(authorization: string): string | undefined { + if (!authorization || !authorization.toLowerCase().startsWith("bearer ")) { + return undefined; + } + const token = authorization.slice(7).trim(); + return token || undefined; +} + +function parseBasicPassword(authorization: string): string | undefined { + if (!authorization || !authorization.toLowerCase().startsWith("basic ")) { + return undefined; + } + const encoded = authorization.slice(6).trim(); + if (!encoded) { + return undefined; + } + try { + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + const sep = decoded.indexOf(":"); + if (sep < 0) { + return undefined; + } + const password = decoded.slice(sep + 1).trim(); + return password || undefined; + } catch { + return undefined; + } +} + +function isAuthorizedBrowserRequest( + req: IncomingMessage, + auth: { token?: string; password?: string }, +): boolean { + const authorization = firstHeaderValue(req.headers.authorization).trim(); + + if (auth.token) { + const bearer = parseBearerToken(authorization); + if (bearer && safeEqualSecret(bearer, auth.token)) { + return true; + } + } + + if (auth.password) { + const passwordHeader = firstHeaderValue(req.headers["x-openclaw-password"]).trim(); + if (passwordHeader && safeEqualSecret(passwordHeader, auth.password)) { + return true; + } + + const basicPassword = parseBasicPassword(authorization); + if (basicPassword && safeEqualSecret(basicPassword, auth.password)) { + return true; + } + } + + return false; +} + export type BrowserBridge = { server: Server; port: number; @@ -22,6 +85,7 @@ export async function startBrowserBridgeServer(params: { host?: string; port?: number; authToken?: string; + authPassword?: string; onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise; }): Promise { const host = params.host ?? "127.0.0.1"; @@ -43,11 +107,11 @@ export async function startBrowserBridgeServer(params: { }); app.use(express.json({ limit: "1mb" })); - const authToken = params.authToken?.trim(); - if (authToken) { + const authToken = params.authToken?.trim() || undefined; + const authPassword = params.authPassword?.trim() || undefined; + if (authToken || authPassword) { app.use((req, res, next) => { - const auth = String(req.headers.authorization ?? "").trim(); - if (auth === `Bearer ${authToken}`) { + if (isAuthorizedBrowserRequest(req, { token: authToken, password: authPassword })) { return next(); } res.status(401).send("Unauthorized");