mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(browser): require auth on control HTTP and auto-bootstrap token
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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=<name>`.
|
||||
|
||||
If gateway auth is configured, browser HTTP routes require auth too:
|
||||
|
||||
- `Authorization: Bearer <gateway token>`
|
||||
- `x-openclaw-password: <gateway password>` or HTTP Basic auth with that password
|
||||
|
||||
### Playwright requirement
|
||||
|
||||
Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require
|
||||
|
||||
106
src/browser/client-fetch.loopback-auth.test.ts
Normal file
106
src/browser/client-fetch.loopback-auth.test.ts
Normal file
@@ -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<typeof import("../config/config.js")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<T>(
|
||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||
try {
|
||||
if (isAbsoluteHttp(url)) {
|
||||
return await fetchHttpJson<T>(url, { ...init, timeoutMs });
|
||||
const httpInit = withLoopbackBrowserAuth(url, init);
|
||||
return await fetchHttpJson<T>(url, { ...httpInit, timeoutMs });
|
||||
}
|
||||
const started = await startBrowserControlServiceFromConfig();
|
||||
if (!started) {
|
||||
|
||||
123
src/browser/control-auth.auto-token.test.ts
Normal file
123
src/browser/control-auth.auto-token.test.ts
Normal file
@@ -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<typeof import("../config/config.js")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
88
src/browser/control-auth.ts
Normal file
88
src/browser/control-auth.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<BrowserSer
|
||||
if (!resolved.enabled) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const ensured = await ensureBrowserControlAuth({ cfg });
|
||||
if (ensured.generatedToken) {
|
||||
logService.info("No browser auth configured; generated gateway.auth.token automatically.");
|
||||
}
|
||||
} catch (err) {
|
||||
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
||||
}
|
||||
|
||||
state = {
|
||||
server: null,
|
||||
|
||||
109
src/browser/server.auth-token-gates-http.test.ts
Normal file
109
src/browser/server.auth-token-gates-http.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createServer, type AddressInfo } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let testPort = 0;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
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<typeof import("./server-context.js")>();
|
||||
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<void>((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<void>((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 });
|
||||
});
|
||||
});
|
||||
@@ -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<BrowserServerState | null> {
|
||||
if (state) {
|
||||
return state;
|
||||
@@ -23,6 +86,17 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
return null;
|
||||
}
|
||||
|
||||
let browserAuth = resolveBrowserControlAuth(cfg);
|
||||
try {
|
||||
const ensured = await ensureBrowserControlAuth({ cfg });
|
||||
browserAuth = ensured.auth;
|
||||
if (ensured.generatedToken) {
|
||||
logServer.info("No browser auth configured; generated gateway.auth.token automatically.");
|
||||
}
|
||||
} catch (err) {
|
||||
logServer.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use((req, res, next) => {
|
||||
const ctrl = new AbortController();
|
||||
@@ -39,6 +113,15 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
});
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
if (browserAuth.token || browserAuth.password) {
|
||||
app.use((req, res, next) => {
|
||||
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<BrowserServ
|
||||
});
|
||||
}
|
||||
|
||||
logServer.info(`Browser control listening on http://127.0.0.1:${port}/`);
|
||||
const authMode = browserAuth.token ? "token" : browserAuth.password ? "password" : "off";
|
||||
logServer.info(`Browser control listening on http://127.0.0.1:${port}/ (auth=${authMode})`);
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,52 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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<typeof resolveBrowserConfig>;
|
||||
@@ -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<Secu
|
||||
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
|
||||
|
||||
findings.push(...collectGatewayConfigFindings(cfg, env));
|
||||
findings.push(...collectBrowserControlFindings(cfg));
|
||||
findings.push(...collectBrowserControlFindings(cfg, env));
|
||||
findings.push(...collectLoggingFindings(cfg));
|
||||
findings.push(...collectElevatedFindings(cfg));
|
||||
findings.push(...collectHooksHardeningFindings(cfg));
|
||||
|
||||
Reference in New Issue
Block a user