diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e628cf5e..72f95f72f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index b1aa01451f..6e3c9348d8 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -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 = diff --git a/src/browser/bridge-auth-registry.ts b/src/browser/bridge-auth-registry.ts new file mode 100644 index 0000000000..ef9346bf34 --- /dev/null +++ b/src/browser/bridge-auth-registry.ts @@ -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(); + +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); +} diff --git a/src/browser/bridge-server.auth.test.ts b/src/browser/bridge-server.auth.test.ts index de0ef63668..e5b3904b10 100644 --- a/src/browser/bridge-server.auth.test.ts +++ b/src/browser/bridge-server.auth.test.ts @@ -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); + }); }); diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts index cb69d510fe..4ee73167ae 100644 --- a/src/browser/bridge-server.ts +++ b/src/browser/bridge-server.ts @@ -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; }): Promise { 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 { + try { + const address = server.address() as AddressInfo | null; + if (address?.port) { + deleteBridgeAuthForPort(address.port); + } + } catch { + // ignore + } await new Promise((resolve) => { server.close(() => resolve()); }); diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index 3c671b27ed..bcc8f9fadd 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -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 }; } diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index 91f56fd51b..1925649bb4 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -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:/...`, + ); + } const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; const expectedPath = redirectUrl.pathname || "/"; diff --git a/src/media/server.ts b/src/media/server.ts index 6f7543b1b2..62dd5ef2d7 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -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)}`));