diff --git a/CHANGELOG.md b/CHANGELOG.md index 521368ffd16..0e7532dd75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. +- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. - Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 9d164fc4ea0..b03a0daa4fc 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2145,8 +2145,9 @@ See [Plugins](/tools/plugin). - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). +- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). -- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds. +- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index 9c14794a58e..f03235daddf 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -16,6 +16,8 @@ export function attachGatewayWsHandlers(params: { resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; + /** Browser-origin fallback limiter (loopback is never exempt). */ + browserRateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; logGateway: ReturnType; @@ -41,6 +43,7 @@ export function attachGatewayWsHandlers(params: { canvasHostServerPort: params.canvasHostServerPort, resolvedAuth: params.resolvedAuth, rateLimiter: params.rateLimiter, + browserRateLimiter: params.browserRateLimiter, gatewayMethods: params.gatewayMethods, events: params.events, logGateway: params.logGateway, diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 83a97644d19..38668de7f40 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -672,6 +672,17 @@ describe("gateway server auth/connect", () => { ws.close(); }); + test("rejects non-local browser origins for non-control-ui clients", async () => { + const ws = await openWs(port, { origin: "https://attacker.example" }); + const res = await connectReq(ws, { + token: "secret", + client: TEST_OPERATOR_CLIENT, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("origin not allowed"); + ws.close(); + }); + test("returns control ui hint when token is missing", async () => { const ws = await openWs(port, { origin: originForPort(port) }); const res = await connectReq(ws, { @@ -701,6 +712,27 @@ describe("gateway server auth/connect", () => { ); ws.close(); }); + + test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => { + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true }, + }; + await withGatewayServer(async ({ port }) => { + const firstWs = await openWs(port, { origin: originForPort(port) }); + const first = await connectReq(firstWs, { token: "wrong" }); + expect(first.ok).toBe(false); + expect(first.error?.message ?? "").not.toContain("retry later"); + firstWs.close(); + + const secondWs = await openWs(port, { origin: originForPort(port) }); + const second = await connectReq(secondWs, { token: "wrong" }); + expect(second.ok).toBe(false); + expect(second.error?.message ?? "").toContain("retry later"); + secondWs.close(); + }); + }); }); describe("explicit none auth", () => { @@ -1214,6 +1246,43 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("does not silently auto-pair non-control-ui browser clients on loopback", async () => { + const { listDevicePairing } = await import("../infra/device-pairing.js"); + const { randomUUID } = await import("node:crypto"); + const os = await import("node:os"); + const path = await import("node:path"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + + const browserWs = await openWs(port, { origin: originForPort(port) }); + const nonce = await readConnectChallengeNonce(browserWs); + const { identity, device } = await createSignedDevice({ + token: "secret", + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`), + nonce, + }); + const res = await connectReq(browserWs, { + token: "secret", + scopes: ["operator.admin"], + client: TEST_OPERATOR_CLIENT, + device, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + + const pairing = await listDevicePairing(); + const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId); + expect(pending).toBeTruthy(); + expect(pending?.silent).toBe(false); + + browserWs.close(); + await server.close(); + restoreGatewayToken(prevToken); + }); + test("merges remote node/operator pairing requests for the same unpaired device", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index fdca08c2677..8b368539469 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -316,6 +316,11 @@ export async function startGatewayServer( const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig ? createAuthRateLimiter(rateLimitConfig) : undefined; + // Always keep a browser-origin fallback limiter for WS auth attempts. + const browserAuthRateLimiter: AuthRateLimiter = createAuthRateLimiter({ + ...rateLimitConfig, + exemptLoopback: false, + }); let controlUiRootState: ControlUiRootState | undefined; if (controlUiRootOverride) { @@ -574,6 +579,7 @@ export async function startGatewayServer( canvasHostServerPort, resolvedAuth, rateLimiter: authRateLimiter, + browserRateLimiter: browserAuthRateLimiter, gatewayMethods, events: GATEWAY_EVENTS, logGateway: log, @@ -777,6 +783,7 @@ export async function startGatewayServer( } skillsChangeUnsub(); authRateLimiter?.dispose(); + browserAuthRateLimiter.dispose(); channelHealthMonitor?.stop(); await close(opts); }, diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index e7c9d458f8f..3abc8d6e1b9 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -65,6 +65,8 @@ export function attachGatewayWsConnectionHandler(params: { resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; + /** Browser-origin fallback limiter (loopback is never exempt). */ + browserRateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; logGateway: SubsystemLogger; @@ -90,6 +92,7 @@ export function attachGatewayWsConnectionHandler(params: { canvasHostServerPort, resolvedAuth, rateLimiter, + browserRateLimiter, gatewayMethods, events, logGateway, @@ -278,6 +281,7 @@ export function attachGatewayWsConnectionHandler(params: { connectNonce, resolvedAuth, rateLimiter, + browserRateLimiter, gatewayMethods, events, extraHandlers, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9708325009f..0d694d12529 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -99,6 +99,8 @@ export function attachGatewayWsMessageHandler(params: { resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; + /** Browser-origin fallback limiter (loopback is never exempt). */ + browserRateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; extraHandlers: GatewayRequestHandlers; @@ -130,6 +132,7 @@ export function attachGatewayWsMessageHandler(params: { connectNonce, resolvedAuth, rateLimiter, + browserRateLimiter, gatewayMethods, events, extraHandlers, @@ -192,6 +195,12 @@ export function attachGatewayWsMessageHandler(params: { const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); const unauthorizedFloodGuard = new UnauthorizedFloodGuard(); + const hasBrowserOriginHeader = Boolean(requestOrigin && requestOrigin.trim() !== ""); + const enforceBrowserOriginForAnyClient = hasBrowserOriginHeader && !hasProxyHeaders; + const browserRateLimitClientIp = + hasBrowserOriginHeader && isLoopbackAddress(clientIp) ? "198.18.0.1" : clientIp; + const authRateLimiter = + hasBrowserOriginHeader && browserRateLimiter ? browserRateLimiter : rateLimiter; socket.on("message", async (data) => { if (isClosed()) { @@ -329,7 +338,7 @@ export function attachGatewayWsMessageHandler(params: { const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; const isWebchat = isWebchatConnect(connectParams); - if (isControlUi || isWebchat) { + if (enforceBrowserOriginForAnyClient || isControlUi || isWebchat) { const originCheck = checkBrowserOrigin({ requestHost, origin: requestOrigin, @@ -377,8 +386,8 @@ export function attachGatewayWsMessageHandler(params: { req: upgradeReq, trustedProxies, allowRealIpFallback, - rateLimiter, - clientIp, + rateLimiter: authRateLimiter, + clientIp: browserRateLimitClientIp, }); const rejectUnauthorized = (failedAuth: GatewayAuthResult) => { markHandshakeFailure("unauthorized", { @@ -556,8 +565,8 @@ export function attachGatewayWsMessageHandler(params: { deviceId: device?.id, role, scopes, - rateLimiter, - clientIp, + rateLimiter: authRateLimiter, + clientIp: browserRateLimitClientIp, verifyDeviceToken, })); if (!authOk) { @@ -613,11 +622,15 @@ export function attachGatewayWsMessageHandler(params: { const requirePairing = async ( reason: "not-paired" | "role-upgrade" | "scope-upgrade", ) => { + const allowSilentLocalPairing = + isLocalClient && + (!hasBrowserOriginHeader || isControlUi || isWebchat) && + (reason === "not-paired" || reason === "scope-upgrade"); const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, ...clientAccessMetadata, - silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"), + silent: allowSilentLocalPairing, }); const context = buildRequestContext(); if (pairing.request.silent === true) {