mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(security): enforce sandbox bridge auth
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
34
src/browser/bridge-auth-registry.ts
Normal file
34
src/browser/bridge-auth-registry.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || "/";
|
||||
|
||||
|
||||
@@ -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)}`));
|
||||
|
||||
Reference in New Issue
Block a user