From 9230a2ae14307740a13ada7afd6dcfab34e0287f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Feb 2026 02:01:57 +0100 Subject: [PATCH] fix(browser): require auth on control HTTP and auto-bootstrap token --- CHANGELOG.md | 1 + docs/tools/browser.md | 6 + .../client-fetch.loopback-auth.test.ts | 106 +++++++++++++++ src/browser/client-fetch.ts | 41 +++++- src/browser/control-auth.auto-token.test.ts | 123 ++++++++++++++++++ src/browser/control-auth.ts | 88 +++++++++++++ src/browser/control-service.ts | 9 ++ .../server.auth-token-gates-http.test.ts | 109 ++++++++++++++++ src/browser/server.ts | 88 ++++++++++++- src/security/audit.test.ts | 46 +++++++ src/security/audit.ts | 22 +++- 11 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 src/browser/client-fetch.loopback-auth.test.ts create mode 100644 src/browser/control-auth.auto-token.test.ts create mode 100644 src/browser/control-auth.ts create mode 100644 src/browser/server.auth-token-gates-http.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3627caa6a0a..6165d5e3e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal. - Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk. - Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra. +- Security/Browser: require auth for loopback browser control HTTP routes, auto-generate `gateway.auth.token` when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle. - Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra. - Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini. - Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 848977d1e69..74309231432 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -192,6 +192,7 @@ Notes: Key ideas: - Browser control is loopback-only; access flows through the Gateway’s auth or node pairing. +- If browser control is enabled and no auth is configured, OpenClaw auto-generates `gateway.auth.token` on startup and persists it to config. - Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure. - Treat remote CDP URLs/tokens as secrets; prefer env vars or a secrets manager. @@ -315,6 +316,11 @@ For local integrations only, the Gateway exposes a small loopback HTTP API: All endpoints accept `?profile=`. +If gateway auth is configured, browser HTTP routes require auth too: + +- `Authorization: Bearer ` +- `x-openclaw-password: ` or HTTP Basic auth with that password + ### Playwright requirement Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts new file mode 100644 index 00000000000..27f2dd8594d --- /dev/null +++ b/src/browser/client-fetch.loopback-auth.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({ + gateway: { + auth: { + token: "loopback-token", + }, + }, + })), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: mocks.loadConfig, + }; +}); + +vi.mock("./control-service.js", () => ({ + createBrowserControlContext: vi.fn(() => ({})), + startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })), +})); + +vi.mock("./routes/dispatcher.js", () => ({ + createBrowserRouteDispatcher: vi.fn(() => ({ + dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })), + })), +})); + +import { fetchBrowserJson } from "./client-fetch.js"; + +describe("fetchBrowserJson loopback auth", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mocks.loadConfig.mockReset(); + mocks.loadConfig.mockReturnValue({ + gateway: { + auth: { + token: "loopback-token", + }, + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("adds bearer auth for loopback absolute HTTP URLs", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const res = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/"); + expect(res.ok).toBe(true); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer loopback-token"); + }); + + it("does not inject auth for non-loopback absolute URLs", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchBrowserJson<{ ok: boolean }>("http://example.com/"); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBeNull(); + }); + + it("keeps caller-supplied auth header", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchBrowserJson<{ ok: boolean }>("http://localhost:18888/", { + headers: { + Authorization: "Bearer caller-token", + }, + }); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer caller-token"); + }); +}); diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index 1a5a835d1be..3c671b27ed1 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -1,4 +1,6 @@ import { formatCliCommand } from "../cli/command-format.js"; +import { loadConfig } from "../config/config.js"; +import { resolveBrowserControlAuth } from "./control-auth.js"; import { createBrowserControlContext, startBrowserControlServiceFromConfig, @@ -9,6 +11,42 @@ function isAbsoluteHttp(url: string): boolean { return /^https?:\/\//i.test(url.trim()); } +function isLoopbackHttpUrl(url: string): boolean { + try { + const host = new URL(url).hostname.trim().toLowerCase(); + return host === "127.0.0.1" || host === "localhost" || host === "::1"; + } catch { + return false; + } +} + +function withLoopbackBrowserAuth( + url: string, + init: (RequestInit & { timeoutMs?: number }) | undefined, +): RequestInit & { timeoutMs?: number } { + const headers = new Headers(init?.headers ?? {}); + if (headers.has("authorization") || headers.has("x-openclaw-password")) { + return { ...init, headers }; + } + if (!isLoopbackHttpUrl(url)) { + return { ...init, headers }; + } + + try { + const cfg = loadConfig(); + const auth = resolveBrowserControlAuth(cfg); + if (auth.token) { + headers.set("Authorization", `Bearer ${auth.token}`); + } else if (auth.password) { + headers.set("x-openclaw-password", auth.password); + } + } catch { + // ignore config/auth lookup failures and continue without auth headers + } + + return { ...init, headers }; +} + function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { const hint = isAbsoluteHttp(url) ? "If this is a sandboxed session, ensure the sandbox browser is running and try again." @@ -69,7 +107,8 @@ export async function fetchBrowserJson( const timeoutMs = init?.timeoutMs ?? 5000; try { if (isAbsoluteHttp(url)) { - return await fetchHttpJson(url, { ...init, timeoutMs }); + const httpInit = withLoopbackBrowserAuth(url, init); + return await fetchHttpJson(url, { ...httpInit, timeoutMs }); } const started = await startBrowserControlServiceFromConfig(); if (!started) { diff --git a/src/browser/control-auth.auto-token.test.ts b/src/browser/control-auth.auto-token.test.ts new file mode 100644 index 00000000000..0c2ffee811f --- /dev/null +++ b/src/browser/control-auth.auto-token.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn<() => OpenClawConfig>(), + writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: mocks.loadConfig, + writeConfigFile: mocks.writeConfigFile, + }; +}); + +import { ensureBrowserControlAuth } from "./control-auth.js"; + +describe("ensureBrowserControlAuth", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mocks.loadConfig.mockReset(); + mocks.writeConfigFile.mockReset(); + }); + + it("returns existing auth and skips writes", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + token: "already-set", + }, + }, + }; + + const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(result).toEqual({ auth: { token: "already-set" } }); + expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("auto-generates and persists a token when auth is missing", async () => { + const cfg: OpenClawConfig = { + browser: { + enabled: true, + }, + }; + mocks.loadConfig.mockReturnValue({ + browser: { + enabled: true, + }, + }); + + const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1); + const persisted = mocks.writeConfigFile.mock.calls[0]?.[0]; + expect(persisted?.gateway?.auth?.mode).toBe("token"); + expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken); + }); + + it("skips auto-generation in test env", async () => { + const cfg: OpenClawConfig = { + browser: { + enabled: true, + }, + }; + + const result = await ensureBrowserControlAuth({ + cfg, + env: { NODE_ENV: "test" } as NodeJS.ProcessEnv, + }); + + expect(result).toEqual({ auth: {} }); + expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("respects explicit password mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + }, + }, + browser: { + enabled: true, + }, + }; + + const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(result).toEqual({ auth: {} }); + expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("reuses auth from latest config snapshot", async () => { + const cfg: OpenClawConfig = { + browser: { + enabled: true, + }, + }; + mocks.loadConfig.mockReturnValue({ + gateway: { + auth: { + token: "latest-token", + }, + }, + browser: { + enabled: true, + }, + }); + + const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(result).toEqual({ auth: { token: "latest-token" } }); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/control-auth.ts b/src/browser/control-auth.ts new file mode 100644 index 00000000000..8c828bcaad1 --- /dev/null +++ b/src/browser/control-auth.ts @@ -0,0 +1,88 @@ +import crypto from "node:crypto"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadConfig, writeConfigFile } from "../config/config.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; + +export type BrowserControlAuth = { + token?: string; + password?: string; +}; + +export function resolveBrowserControlAuth( + cfg: OpenClawConfig | undefined, + env: NodeJS.ProcessEnv = process.env, +): BrowserControlAuth { + const auth = resolveGatewayAuth({ + authConfig: cfg?.gateway?.auth, + env, + tailscaleMode: cfg?.gateway?.tailscale?.mode, + }); + const token = typeof auth.token === "string" ? auth.token.trim() : ""; + const password = typeof auth.password === "string" ? auth.password.trim() : ""; + return { + token: token || undefined, + password: password || undefined, + }; +} + +function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean { + const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase(); + if (nodeEnv === "test") { + return false; + } + const vitest = (env.VITEST ?? "").trim().toLowerCase(); + if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") { + return false; + } + return true; +} + +export async function ensureBrowserControlAuth(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): Promise<{ + auth: BrowserControlAuth; + generatedToken?: string; +}> { + const env = params.env ?? process.env; + const auth = resolveBrowserControlAuth(params.cfg, env); + if (auth.token || auth.password) { + return { auth }; + } + if (!shouldAutoGenerateBrowserAuth(env)) { + return { auth }; + } + + // Respect explicit password mode even if currently unset. + if (params.cfg.gateway?.auth?.mode === "password") { + return { auth }; + } + + // Re-read latest config to avoid racing with concurrent config writers. + const latestCfg = loadConfig(); + const latestAuth = resolveBrowserControlAuth(latestCfg, env); + if (latestAuth.token || latestAuth.password) { + return { auth: latestAuth }; + } + if (latestCfg.gateway?.auth?.mode === "password") { + return { auth: latestAuth }; + } + + const generatedToken = crypto.randomBytes(24).toString("hex"); + const nextCfg: OpenClawConfig = { + ...latestCfg, + gateway: { + ...latestCfg.gateway, + auth: { + ...latestCfg.gateway?.auth, + mode: "token", + token: generatedToken, + }, + }, + }; + await writeConfigFile(nextCfg); + return { + auth: { token: generatedToken }, + generatedToken, + }; +} diff --git a/src/browser/control-service.ts b/src/browser/control-service.ts index 30a74471178..93bb89e93dd 100644 --- a/src/browser/control-service.ts +++ b/src/browser/control-service.ts @@ -1,6 +1,7 @@ import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; +import { ensureBrowserControlAuth } from "./control-auth.js"; import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; @@ -28,6 +29,14 @@ export async function startBrowserControlServiceFromConfig(): Promise { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({ + gateway: { + auth: { + token: "browser-control-secret", + }, + }, + browser: { + enabled: true, + defaultProfile: "openclaw", + profiles: { + openclaw: { cdpPort: testPort + 1, color: "#FF4500" }, + }, + }, + }), + }; +}); + +vi.mock("./routes/index.js", () => ({ + registerBrowserRoutes(app: { + get: ( + path: string, + handler: (req: unknown, res: { json: (body: unknown) => void }) => void, + ) => void; + }) { + app.get("/", (_req, res) => { + res.json({ ok: true }); + }); + }, +})); + +vi.mock("./server-context.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createBrowserRouteContext: vi.fn(() => ({ + forProfile: vi.fn(() => ({ + stopRunningBrowser: vi.fn(async () => {}), + })), + })), + }; +}); + +describe("browser control HTTP auth", () => { + beforeEach(async () => { + prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT; + + const probe = createServer(); + await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => resolve()); + }); + const addr = probe.address() as AddressInfo; + testPort = addr.port; + await new Promise((resolve) => probe.close(() => resolve())); + + process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + if (prevGatewayPort === undefined) { + delete process.env.OPENCLAW_GATEWAY_PORT; + } else { + process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort; + } + + const { stopBrowserControlServer } = await import("./server.js"); + await stopBrowserControlServer(); + }); + + it("requires bearer auth for standalone browser HTTP routes", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + const started = await startBrowserControlServerFromConfig(); + expect(started?.port).toBe(testPort); + + const base = `http://127.0.0.1:${testPort}`; + + const missingAuth = await realFetch(`${base}/`); + expect(missingAuth.status).toBe(401); + expect(await missingAuth.text()).toContain("Unauthorized"); + + const badAuth = await realFetch(`${base}/`, { + headers: { + Authorization: "Bearer wrong-token", + }, + }); + expect(badAuth.status).toBe(401); + + const ok = await realFetch(`${base}/`, { + headers: { + Authorization: "Bearer browser-control-secret", + }, + }); + expect(ok.status).toBe(200); + expect((await ok.json()) as { ok: boolean }).toEqual({ ok: true }); + }); +}); diff --git a/src/browser/server.ts b/src/browser/server.ts index 345f0449732..2f734f031d5 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -1,9 +1,11 @@ -import type { Server } from "node:http"; +import type { IncomingMessage, Server } from "node:http"; import express from "express"; import type { BrowserRouteRegistrar } from "./routes/types.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { safeEqualSecret } from "../security/secret-equal.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; +import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js"; import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; import { registerBrowserRoutes } from "./routes/index.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; @@ -12,6 +14,67 @@ let state: BrowserServerState | null = null; const log = createSubsystemLogger("browser"); const logServer = log.child("server"); +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 async function startBrowserControlServerFromConfig(): Promise { if (state) { return state; @@ -23,6 +86,17 @@ export async function startBrowserControlServerFromConfig(): Promise { const ctrl = new AbortController(); @@ -39,6 +113,15 @@ export async function startBrowserControlServerFromConfig(): Promise { + if (isAuthorizedBrowserRequest(req, browserAuth)) { + return next(); + } + res.status(401).send("Unauthorized"); + }); + } + const ctx = createBrowserRouteContext({ getState: () => state, }); @@ -76,7 +159,8 @@ export async function startBrowserControlServerFromConfig(): Promise { ); }); + it("flags browser control without auth when browser is enabled", async () => { + const cfg: OpenClawConfig = { + gateway: { + controlUi: { enabled: false }, + auth: {}, + }, + browser: { + enabled: true, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "browser.control_no_auth", severity: "critical" }), + ]), + ); + }); + + it("does not flag browser control auth when gateway token is configured", async () => { + const cfg: OpenClawConfig = { + gateway: { + controlUi: { enabled: false }, + auth: { token: "very-long-browser-token-0123456789" }, + }, + browser: { + enabled: true, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings.some((f) => f.checkId === "browser.control_no_auth")).toBe(false); + }); + it("warns when remote CDP uses HTTP", async () => { const cfg: OpenClawConfig = { browser: { diff --git a/src/security/audit.ts b/src/security/audit.ts index 02fac93135d..34005c1c34d 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -2,6 +2,7 @@ import type { ChannelId } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ExecFn } from "./windows-acl.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; +import { resolveBrowserControlAuth } from "../browser/control-auth.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -364,7 +365,10 @@ function collectGatewayConfigFindings( return findings; } -function collectBrowserControlFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { +function collectBrowserControlFindings( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; let resolved: ReturnType; @@ -385,6 +389,20 @@ function collectBrowserControlFindings(cfg: OpenClawConfig): SecurityAuditFindin return findings; } + const browserAuth = resolveBrowserControlAuth(cfg, env); + if (!browserAuth.token && !browserAuth.password) { + findings.push({ + checkId: "browser.control_no_auth", + severity: "critical", + title: "Browser control has no auth", + detail: + "Browser control HTTP routes are enabled but no gateway.auth token/password is configured. " + + "Any local process (or SSRF to loopback) can call browser control endpoints.", + remediation: + "Set gateway.auth.token (recommended) or gateway.auth.password so browser control HTTP routes require authentication. Restarting the gateway will auto-generate gateway.auth.token when browser control is enabled.", + }); + } + for (const name of Object.keys(resolved.profiles)) { const profile = resolveProfile(resolved, name); if (!profile || profile.cdpIsLoopback) { @@ -924,7 +942,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise