From e4d22fb07a2b865ba5c1e89704a5c212a4736b27 Mon Sep 17 00:00:00 2001 From: Agent Date: Sun, 1 Mar 2026 21:39:39 +0000 Subject: [PATCH] fix(browser): fail closed browser auth bootstrap --- CHANGELOG.md | 1 + src/browser/server.auth-fail-closed.test.ts | 92 +++++++++++++++++++++ src/browser/server.ts | 11 +++ 3 files changed, 104 insertions(+) create mode 100644 src/browser/server.auth-fail-closed.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0199e45b526..57eb035549d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai - CLI/Startup (Raspberry Pi + small hosts): speed up startup by avoiding unnecessary plugin preload on fast routes, adding root `--version` fast-path bootstrap bypass, parallelizing status JSON/non-JSON scans where safe, and enabling Node compile cache at startup with env override compatibility (`NODE_COMPILE_CACHE`, `NODE_DISABLE_COMPILE_CACHE`). (#5871) Thanks @BookCatKid and @vincentkoc for raising startup reports, and @lupuletic for related startup work in #27973. - 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. - 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/src/browser/server.auth-fail-closed.test.ts b/src/browser/server.auth-fail-closed.test.ts new file mode 100644 index 00000000000..67228c5ad4a --- /dev/null +++ b/src/browser/server.auth-fail-closed.test.ts @@ -0,0 +1,92 @@ +import { createServer, type AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + controlPort: 0, + ensureBrowserControlAuth: vi.fn(async () => { + throw new Error("read-only config"); + }), + resolveBrowserControlAuth: vi.fn(() => ({})), + ensureExtensionRelayForProfiles: vi.fn(async () => {}), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({ + browser: { + enabled: true, + }, + }), + }; +}); + +vi.mock("./config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveBrowserConfig: vi.fn(() => ({ + enabled: true, + controlPort: mocks.controlPort, + })), + }; +}); + +vi.mock("./control-auth.js", () => ({ + ensureBrowserControlAuth: mocks.ensureBrowserControlAuth, + resolveBrowserControlAuth: mocks.resolveBrowserControlAuth, +})); + +vi.mock("./routes/index.js", () => ({ + registerBrowserRoutes: vi.fn(() => {}), +})); + +vi.mock("./server-context.js", () => ({ + createBrowserRouteContext: vi.fn(() => ({})), +})); + +vi.mock("./server-lifecycle.js", () => ({ + ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles, + stopKnownBrowserProfiles: vi.fn(async () => {}), +})); + +vi.mock("./pw-ai-state.js", () => ({ + isPwAiLoaded: vi.fn(() => false), +})); + +const { startBrowserControlServerFromConfig, stopBrowserControlServer } = + await import("./server.js"); + +async function getFreePort(): Promise { + 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; + await new Promise((resolve) => probe.close(() => resolve())); + return addr.port; +} + +describe("browser control auth bootstrap failures", () => { + beforeEach(async () => { + mocks.controlPort = await getFreePort(); + mocks.ensureBrowserControlAuth.mockClear(); + mocks.resolveBrowserControlAuth.mockClear(); + mocks.ensureExtensionRelayForProfiles.mockClear(); + }); + + afterEach(async () => { + await stopBrowserControlServer(); + }); + + it("fails closed when auth bootstrap throws and no auth is configured", async () => { + const started = await startBrowserControlServerFromConfig(); + + expect(started).toBeNull(); + expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1); + expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1); + expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/server.ts b/src/browser/server.ts index 60c5586384d..f6a269aee1e 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -30,6 +30,7 @@ export async function startBrowserControlServerFromConfig(): Promise