From 8f3da653b023f397ee55f40ebd9307654cbe7436 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 12:47:06 +0000 Subject: [PATCH] fix: allow control ui token auth without pairing --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 5 +- docs/gateway/security.md | 2 +- docs/web/control-ui.md | 4 +- src/gateway/server.auth.e2e.test.ts | 65 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 11 ++-- 6 files changed, 78 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52378f68..9cc7b5370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot - Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags ### Fixes +- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. - Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. - BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 020ca9c90..3b16be5b1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2847,8 +2847,9 @@ Control UI base path: - `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. - Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`. - Default: root (`/`) (unchanged). -- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity). - Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`. +- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips + device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS + (Tailscale Serve) or `127.0.0.1`. Related docs: - [Control UI](/web/control-ui) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 48e3fa59c..ed0054411 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order: The Control UI needs a **secure context** (HTTPS or localhost) to generate device identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back -to **token-only auth** on plain HTTP and skips device pairing. This is a security +to **token-only auth** and skips device pairing (even on HTTPS). This is a security downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`. `clawdbot security audit` warns when this setting is enabled. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index ede005259..188479679 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity. } ``` -This disables device identity + pairing for the Control UI. Use only if you -trust the network. +This disables device identity + pairing for the Control UI (even on HTTPS). Use +only if you trust the network. See [Tailscale](/gateway/tailscale) for HTTPS setup guidance. diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index b97a2d321..a2645a75d 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { getHandshakeTimeoutMs } from "./server-constants.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; import { connectReq, getFreePort, @@ -286,6 +287,70 @@ describe("gateway server auth/connect", () => { } }); + test("allows control ui with device identity when insecure auth is enabled", async () => { + testState.gatewayControlUi = { allowInsecureAuth: true }; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + trustedProxies: ["127.0.0.1"], + }, + } as any); + const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; + const port = await getFreePort(); + const server = await startGatewayServer(port); + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { "x-forwarded-for": "203.0.113.10" }, + }); + const challengePromise = onceMessage<{ payload?: unknown }>( + ws, + (o) => o.type === "event" && o.event === "connect.challenge", + ); + await new Promise((resolve) => ws.once("open", resolve)); + const challenge = await challengePromise; + const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + role: "operator", + scopes: [], + signedAtMs, + token: "secret", + nonce: String(nonce), + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce: String(nonce), + }; + const res = await connectReq(ws, { + token: "secret", + device, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("accepts device token auth for paired device", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 5bca2e5ed..5ef3f26e7 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -318,13 +318,13 @@ export function attachGatewayWsMessageHandler(params: { let devicePublicKey: string | null = null; const hasTokenAuth = Boolean(connectParams.auth?.token); const hasPasswordAuth = Boolean(connectParams.auth?.password); + const hasSharedAuth = hasTokenAuth || hasPasswordAuth; const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; + const allowInsecureControlUi = + isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; if (!device) { - const allowInsecureControlUi = - isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; - const canSkipDevice = - isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth; + const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth; if (isControlUi && !allowInsecureControlUi) { const errorMessage = "control ui requires HTTPS or localhost (secure context)"; @@ -569,7 +569,8 @@ export function attachGatewayWsMessageHandler(params: { return; } - if (device && devicePublicKey) { + const skipPairing = allowInsecureControlUi && hasSharedAuth; + if (device && devicePublicKey && !skipPairing) { const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ deviceId: device.id,