OpenClaw 2026.1.29-beta.1 < 2026.2.14 - Authentication Bypass in Sandbox Browser Bridge Server
Description
OpenClaw versions 2026.1.29-beta.1 prior to 2026.2.14 contain a vulnerability in the sandbox browser bridge server in which it accepts requests without requiring gateway authentication, allowing local attackers to access browser control endpoints. A local attacker can enumerate tabs, retrieve WebSocket URLs, execute JavaScript, and exfiltrate cookies and session data from authenticated browser contexts.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.1.29-beta.1, < 2026.2.14 | 2026.2.14 |
Affected products
1Patches
3cd84885a4ac7test(browser): cover bridge auth registry fallback
1 file changed · +53 −0
src/browser/client-fetch.bridge-auth-registry.test.ts+53 −0 added@@ -0,0 +1,53 @@ +import type { AddressInfo } from "node:net"; +import { createServer } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js"; + +describe("fetchBrowserJson loopback auth (bridge auth registry)", () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("falls back to per-port bridge auth when config auth is not available", async () => { + vi.doMock("../config/config.js", async (importOriginal) => { + const original = await importOriginal<typeof import("../config/config.js")>(); + return { + ...original, + loadConfig: () => ({}), + }; + }); + + const server = createServer((req, res) => { + const auth = String(req.headers.authorization ?? "").trim(); + if (auth !== "Bearer registry-token") { + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); + return; + } + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true })); + }); + + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const port = (server.address() as AddressInfo).port; + setBridgeAuthForPort(port, { token: "registry-token" }); + + try { + const { fetchBrowserJson } = await import("./client-fetch.js"); + const result = await fetchBrowserJson<{ ok: boolean }>(`http://127.0.0.1:${port}/`, { + timeoutMs: 2000, + }); + expect(result.ok).toBe(true); + } finally { + deleteBridgeAuthForPort(port); + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); +});
6dd6bce997c4fix(security): enforce sandbox bridge auth
8 files changed · +108 −5
CHANGELOG.md+1 −0 modified@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - 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: bind local helper servers to loopback and fail closed on non-loopback OAuth callback hosts (reduces localhost/LAN attack surface). - 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.
src/agents/sandbox/browser.ts+15 −3 modified@@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; @@ -149,13 +150,24 @@ 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; + + let desiredAuthToken = params.bridgeAuth?.token?.trim() || undefined; + let desiredAuthPassword = params.bridgeAuth?.password?.trim() || undefined; + if (!desiredAuthToken && !desiredAuthPassword) { + // Always require auth for the sandbox bridge server, even if gateway auth + // mode doesn't produce a shared secret (e.g. trusted-proxy). + // Keep it stable across calls by reusing the existing bridge auth. + desiredAuthToken = existing?.authToken; + desiredAuthPassword = existing?.authPassword; + if (!desiredAuthToken && !desiredAuthPassword) { + desiredAuthToken = crypto.randomBytes(24).toString("hex"); + } + } + const shouldReuse = existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; const authMatches =
src/browser/bridge-auth-registry.ts+34 −0 added@@ -0,0 +1,34 @@ +type BridgeAuth = { + token?: string; + password?: string; +}; + +// In-process registry for loopback-only bridge servers that require auth, but +// are addressed via dynamic ephemeral ports (e.g. sandbox browser bridge). +const authByPort = new Map<number, BridgeAuth>(); + +export function setBridgeAuthForPort(port: number, auth: BridgeAuth): void { + if (!Number.isFinite(port) || port <= 0) { + return; + } + const token = typeof auth.token === "string" ? auth.token.trim() : ""; + const password = typeof auth.password === "string" ? auth.password.trim() : ""; + authByPort.set(port, { + token: token || undefined, + password: password || undefined, + }); +} + +export function getBridgeAuthForPort(port: number): BridgeAuth | undefined { + if (!Number.isFinite(port) || port <= 0) { + return undefined; + } + return authByPort.get(port); +} + +export function deleteBridgeAuthForPort(port: number): void { + if (!Number.isFinite(port) || port <= 0) { + return; + } + authByPort.delete(port); +}
src/browser/bridge-server.auth.test.ts+8 −0 modified@@ -73,4 +73,12 @@ describe("startBrowserBridgeServer auth", () => { }); expect(authed.status).toBe(200); }); + + it("requires auth params", async () => { + await expect( + startBrowserBridgeServer({ + resolved: buildResolvedConfig(), + }), + ).rejects.toThrow(/requires auth/i); + }); });
src/browser/bridge-server.ts+18 −0 modified@@ -4,7 +4,9 @@ import type { AddressInfo } from "node:net"; import express from "express"; import type { ResolvedBrowserConfig } from "./config.js"; import type { BrowserRouteRegistrar } from "./routes/types.js"; +import { isLoopbackHost } from "../gateway/net.js"; import { safeEqualSecret } from "../security/secret-equal.js"; +import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js"; import { registerBrowserRoutes } from "./routes/index.js"; import { type BrowserServerState, @@ -89,6 +91,9 @@ export async function startBrowserBridgeServer(params: { onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>; }): Promise<BrowserBridge> { const host = params.host ?? "127.0.0.1"; + if (!isLoopbackHost(host)) { + throw new Error(`bridge server must bind to loopback host (got ${host})`); + } const port = params.port ?? 0; const app = express(); @@ -109,6 +114,9 @@ export async function startBrowserBridgeServer(params: { const authToken = params.authToken?.trim() || undefined; const authPassword = params.authPassword?.trim() || undefined; + if (!authToken && !authPassword) { + throw new Error("bridge server requires auth (authToken/authPassword missing)"); + } if (authToken || authPassword) { app.use((req, res, next) => { if (isAuthorizedBrowserRequest(req, { token: authToken, password: authPassword })) { @@ -142,11 +150,21 @@ export async function startBrowserBridgeServer(params: { state.port = resolvedPort; state.resolved.controlPort = resolvedPort; + setBridgeAuthForPort(resolvedPort, { token: authToken, password: authPassword }); + const baseUrl = `http://${host}:${resolvedPort}`; return { server, port: resolvedPort, baseUrl, state }; } export async function stopBrowserBridgeServer(server: Server): Promise<void> { + try { + const address = server.address() as AddressInfo | null; + if (address?.port) { + deleteBridgeAuthForPort(address.port); + } + } catch { + // ignore + } await new Promise<void>((resolve) => { server.close(() => resolve()); });
src/browser/client-fetch.ts+25 −1 modified@@ -1,5 +1,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { loadConfig } from "../config/config.js"; +import { getBridgeAuthForPort } from "./bridge-auth-registry.js"; import { resolveBrowserControlAuth } from "./control-auth.js"; import { createBrowserControlContext, @@ -37,13 +38,36 @@ function withLoopbackBrowserAuth( const auth = resolveBrowserControlAuth(cfg); if (auth.token) { headers.set("Authorization", `Bearer ${auth.token}`); - } else if (auth.password) { + return { ...init, headers }; + } + if (auth.password) { headers.set("x-openclaw-password", auth.password); + return { ...init, headers }; } } catch { // ignore config/auth lookup failures and continue without auth headers } + // Sandbox bridge servers can run with per-process ephemeral auth on dynamic ports. + // Fall back to the in-memory registry if config auth is not available. + try { + const parsed = new URL(url); + const port = + parsed.port && Number.parseInt(parsed.port, 10) > 0 + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === "https:" + ? 443 + : 80; + const bridgeAuth = getBridgeAuthForPort(port); + if (bridgeAuth?.token) { + headers.set("Authorization", `Bearer ${bridgeAuth.token}`); + } else if (bridgeAuth?.password) { + headers.set("x-openclaw-password", bridgeAuth.password); + } + } catch { + // ignore + } + return { ...init, headers }; }
src/commands/chutes-oauth.ts+6 −0 modified@@ -8,6 +8,7 @@ import { generateChutesPkce, parseOAuthCallbackInput, } from "../agents/chutes-oauth.js"; +import { isLoopbackHost } from "../gateway/net.js"; type OAuthPrompt = { message: string; @@ -44,6 +45,11 @@ async function waitForLocalCallback(params: { throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`); } const hostname = redirectUrl.hostname || "127.0.0.1"; + if (!isLoopbackHost(hostname)) { + throw new Error( + `Chutes OAuth redirect hostname must be loopback (got ${hostname}). Use http://127.0.0.1:<port>/...`, + ); + } const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; const expectedPath = redirectUrl.pathname || "/";
src/media/server.ts+1 −1 modified@@ -96,7 +96,7 @@ export async function startMediaServer( const app = express(); attachMediaRoutes(app, ttlMs, runtime); return await new Promise((resolve, reject) => { - const server = app.listen(port); + const server = app.listen(port, "127.0.0.1"); server.once("listening", () => resolve(server)); server.once("error", (err) => { runtime.error(danger(`Media server failed: ${String(err)}`));
4711a943e30bfix(browser): authenticate sandbox browser bridge server
6 files changed · +191 −7
CHANGELOG.md+1 −0 modified@@ -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.
src/agents/sandbox/browser-bridges.ts+9 −1 modified@@ -1,3 +1,11 @@ import type { BrowserBridge } from "../../browser/bridge-server.js"; -export const BROWSER_BRIDGES = new Map<string, { bridge: BrowserBridge; containerName: string }>(); +export const BROWSER_BRIDGES = new Map< + string, + { + bridge: BrowserBridge; + containerName: string; + authToken?: string; + authPassword?: string; + } +>();
src/agents/sandbox/browser.ts+17 −2 modified@@ -90,6 +90,7 @@ export async function ensureSandboxBrowser(params: { agentWorkspaceDir: string; cfg: SandboxConfig; evaluateEnabled?: boolean; + bridgeAuth?: { token?: string; password?: string }; }): Promise<SandboxBrowserContext | null> { 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, }); }
src/agents/sandbox/context.ts+20 −0 modified@@ -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 = {
src/browser/bridge-server.auth.test.ts+76 −0 added@@ -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<void> }> = []; + + 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); + }); +});
src/browser/bridge-server.ts+68 −4 modified@@ -1,15 +1,78 @@ 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, createBrowserRouteContext, 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<void>; }): Promise<BrowserBridge> { 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");
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/openclaw/openclaw/commit/4711a943e30bc58016247152ba06472dab09d0b0ghsapatchWEB
- github.com/openclaw/openclaw/commit/6dd6bce997c48752134f2d6ed89b27de01ced7e3ghsapatchWEB
- github.com/openclaw/openclaw/commit/cd84885a4ac78eadb7bf321aae98db9519426d67ghsapatchWEB
- github.com/advisories/GHSA-h9g4-589h-68xvghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-h9g4-589h-68xvghsavendor-advisoryWEB
- www.vulncheck.com/advisories/openclaw-beta-authentication-bypass-in-sandbox-browser-bridge-servermitrethird-party-advisory
- github.com/openclaw/openclaw/releases/tag/v2026.2.14ghsaWEB
News mentions
0No linked articles in our index yet.