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

@@ -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.

View File

@@ -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 =

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

View File

@@ -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 || "/";

View File

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