fix(security): enforce sandbox bridge auth

This commit is contained in:
Peter Steinberger
2026-02-14 13:17:41 +01:00
parent 4711a943e3
commit 6dd6bce997
8 changed files with 108 additions and 5 deletions

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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());
});

View File

@@ -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 };
}