diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3bdd616..ecf10530a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Docs: https://docs.clawd.bot - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. - Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. +### Breaking +- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http + ### Fixes - Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380) - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index dbc157c8c..00fd9e30f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2671,6 +2671,8 @@ 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`. Related docs: - [Control UI](/web/control-ui) diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 85cf1cbd9..fc6682708 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -198,6 +198,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - **Local** connects include loopback and the gateway host’s own tailnet address (so same‑host tailnet binds can still auto‑approve). - All WS clients must include `device` identity during `connect` (operator + node). + Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled. - Non-local connections must sign the server-provided `connect.challenge` nonce. ## TLS + pinning diff --git a/docs/gateway/security.md b/docs/gateway/security.md index cd9a80126..e429205ef 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -52,6 +52,15 @@ When the audit prints findings, treat this as a priority order: 5. **Plugins/extensions**: only load what you explicitly trust. 6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools. +## Control UI over HTTP + +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 +downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`. + +`clawdbot security audit` warns when this setting is enabled. + ## Local session logs live on disk Clawdbot stores session transcripts on disk under `~/.clawdbot/agents//sessions/*.jsonl`. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index ad21290af..803360439 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -31,6 +31,19 @@ See also: [Health checks](/gateway/health) and [Logging](/logging). ## Common Issues +### Control UI fails on HTTP ("device identity required" / "connect failed") + +If you open the dashboard over plain HTTP (e.g. `http://:18789/` or +`http://:18789/`), the browser runs in a **non-secure context** and +blocks WebCrypto, so device identity can’t be generated. + +**Fix:** +- Prefer HTTPS via [Tailscale Serve](/gateway/tailscale). +- Or open locally on the gateway host: `http://127.0.0.1:18789/`. +- If you must stay on HTTP, enable `gateway.controlUi.allowInsecureAuth: true` and + use a gateway token (token-only; no device identity/pairing). See + [Control UI](/web/control-ui#insecure-http). + ### CI Secrets Scan Failed This means `detect-secrets` found new candidates not yet in the baseline. diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index b6219a924..94e769764 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -38,6 +38,11 @@ Almost always a Node/npm PATH issue. Start here: - [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway authentication](/gateway/authentication) +### Control UI fails on HTTP (device identity required) + +- [Gateway troubleshooting](/gateway/troubleshooting) +- [Control UI](/web/control-ui#insecure-http) + ### Service says running, but RPC probe fails - [Gateway troubleshooting](/gateway/troubleshooting) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b1102c47d..0d473c858 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -86,6 +86,33 @@ Then open: Paste the token into the UI settings (sent as `connect.params.auth.token`). +## Insecure HTTP + +If you open the dashboard over plain HTTP (`http://` or `http://`), +the browser runs in a **non-secure context** and blocks WebCrypto. By default, +Clawdbot **blocks** Control UI connections without device identity. + +**Recommended fix:** use HTTPS (Tailscale Serve) or open the UI locally: +- `https:///` (Serve) +- `http://127.0.0.1:18789/` (on the gateway host) + +**Downgrade example (token-only over HTTP):** + +```json5 +{ + gateway: { + controlUi: { allowInsecureAuth: true }, + bind: "tailnet", + auth: { mode: "token", token: "replace-me" } + } +} +``` + +This disables device identity + pairing for the Control UI. Use only if you +trust the network. + +See [Tailscale](/gateway/tailscale) for HTTPS setup guidance. + ## Building the UI The Gateway serves static files from `dist/control-ui`. Build them with: diff --git a/src/config/schema.ts b/src/config/schema.ts index bf25f0adb..324f7189b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -187,6 +187,7 @@ const FIELD_LABELS: Record = { "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", "tools.web.fetch.userAgent": "Web Fetch User-Agent", "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", @@ -345,6 +346,8 @@ const FIELD_HELP: Record = { "gateway.auth.password": "Required for Tailscale funnel.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /clawdbot).", + "gateway.controlUi.allowInsecureAuth": + "Allow Control UI auth over insecure HTTP (token-only; not recommended).", "gateway.http.endpoints.chatCompletions.enabled": "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index ebc65abd4..cb57d72ce 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -51,6 +51,8 @@ export type GatewayControlUiConfig = { enabled?: boolean; /** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */ basePath?: string; + /** Allow token-only auth over insecure HTTP (default: false). */ + allowInsecureAuth?: boolean; }; export type GatewayAuthMode = "token" | "password"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 213f1d1fd..c9754934a 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -282,6 +282,7 @@ export const ClawdbotSchema = z .object({ enabled: z.boolean().optional(), basePath: z.string().optional(), + allowInsecureAuth: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index e3be7fde7..0bc7b8374 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -11,6 +11,7 @@ import { startServerWithClient, testState, } from "./test-helpers.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; installGatewayTestHooks(); @@ -127,6 +128,52 @@ describe("gateway server auth/connect", () => { await server.close(); }); + test("rejects control ui without device identity by default", async () => { + const { server, ws, prevToken } = await startServerWithClient("secret"); + const res = await connectReq(ws, { + token: "secret", + device: null, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("secure context"); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + + test("allows control ui without device identity when insecure auth is enabled", async () => { + testState.gatewayControlUi = { allowInsecureAuth: true }; + const { server, ws, prevToken } = await startServerWithClient("secret"); + const res = await connectReq(ws, { + token: "secret", + device: null, + 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 fa53f5ffe..665a74d05 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -39,6 +39,7 @@ import { validateConnectParams, validateRequestFrame, } from "../../protocol/index.js"; +import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import { handleGatewayRequest } from "../../server-methods.js"; @@ -293,24 +294,53 @@ export function attachGatewayWsMessageHandler(params: { const device = connectParams.device; let devicePublicKey: string | null = null; - // Allow token-authenticated connections (e.g., control-ui) to skip device identity - const hasTokenAuth = !!connectParams.auth?.token; - if (!device && !hasTokenAuth) { - setHandshakeState("failed"); - setCloseCause("device-required", { - client: connectParams.client.id, - clientDisplayName: connectParams.client.displayName, - mode: connectParams.client.mode, - version: connectParams.client.version, - }); - send({ - type: "res", - id: frame.id, - ok: false, - error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"), - }); - close(1008, "device identity required"); - return; + const hasTokenAuth = Boolean(connectParams.auth?.token); + const hasPasswordAuth = Boolean(connectParams.auth?.password); + const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; + + if (!device) { + const allowInsecureControlUi = + isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true; + const canSkipDevice = + isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth; + + if (isControlUi && !allowInsecureControlUi) { + const errorMessage = "control ui requires HTTPS or localhost (secure context)"; + setHandshakeState("failed"); + setCloseCause("control-ui-insecure-auth", { + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, errorMessage), + }); + close(1008, errorMessage); + return; + } + + // Allow token-authenticated connections (e.g., control-ui) to skip device identity + if (!canSkipDevice) { + setHandshakeState("failed"); + setCloseCause("device-required", { + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"), + }); + close(1008, "device identity required"); + return; + } } if (device) { const derivedId = deriveDeviceIdFromPublicKey(device.publicKey); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 57b2319b9..993e64c32 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -210,6 +210,7 @@ export const testState = { cronEnabled: false as boolean | undefined, gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined, gatewayAuth: undefined as Record | undefined, + gatewayControlUi: undefined as Record | undefined, hooksConfig: undefined as HooksConfig | undefined, canvasHostPort: undefined as number | undefined, legacyIssues: [] as Array<{ path: string; message: string }>, @@ -443,6 +444,7 @@ vi.mock("../config/config.js", async () => { : {}; if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind; if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth; + if (testState.gatewayControlUi) fileGateway.controlUi = testState.gatewayControlUi; const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined; const fileCanvasHost = diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index e3668815f..c72683824 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -101,6 +101,7 @@ export function installGatewayTestHooks() { testTailnetIPv4.value = undefined; testState.gatewayBind = undefined; testState.gatewayAuth = undefined; + testState.gatewayControlUi = undefined; testState.hooksConfig = undefined; testState.canvasHostPort = undefined; testState.legacyIssues = []; @@ -280,7 +281,7 @@ export async function connectReq( signature: string; signedAt: number; nonce?: string; - }; + } | null; }, ): Promise { const { randomUUID } = await import("node:crypto"); @@ -294,6 +295,7 @@ export async function connectReq( const role = opts?.role ?? "operator"; const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; const device = (() => { + if (opts?.device === null) return undefined; if (opts?.device) return opts.device; const identity = loadOrCreateDeviceIdentity(); const signedAtMs = Date.now(); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 5232709f9..78e5ce69d 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -227,6 +227,29 @@ describe("security audit", () => { } }); + it("warns when control UI allows insecure auth", async () => { + const cfg: ClawdbotConfig = { + gateway: { + controlUi: { allowInsecureAuth: true }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.control_ui.insecure_auth", + severity: "warn", + }), + ]), + ); + }); + it("warns when multiple DM senders share the main session", async () => { const cfg: ClawdbotConfig = { session: { dmScope: "main" } }; const plugins: ChannelPlugin[] = [ diff --git a/src/security/audit.ts b/src/security/audit.ts index b5ad31aba..87e6e3397 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -235,6 +235,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding }); } + if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { + findings.push({ + checkId: "gateway.control_ui.insecure_auth", + severity: "warn", + title: "Control UI allows insecure HTTP auth", + detail: + "gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.", + remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.", + }); + } + const token = typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; if (auth.mode === "token" && token && token.length < 24) { diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 37ab5e2cd..5f1ab01ff 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -126,15 +126,27 @@ export class GatewayBrowserClient { window.clearTimeout(this.connectTimer); this.connectTimer = null; } - const deviceIdentity = await loadOrCreateDeviceIdentity(); + + // crypto.subtle is only available in secure contexts (HTTPS, localhost). + // Over plain HTTP, we skip device identity and fall back to token-only auth. + // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. + const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; + const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const role = "operator"; - const storedToken = loadDeviceAuthToken({ - deviceId: deviceIdentity.deviceId, - role, - })?.token; - const authToken = storedToken ?? this.opts.token; - const canFallbackToShared = Boolean(storedToken && this.opts.token); + let deviceIdentity: Awaited> | null = null; + let canFallbackToShared = false; + let authToken = this.opts.token; + + if (isSecureContext) { + deviceIdentity = await loadOrCreateDeviceIdentity(); + const storedToken = loadDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role, + })?.token; + authToken = storedToken ?? this.opts.token; + canFallbackToShared = Boolean(storedToken && this.opts.token); + } const auth = authToken || this.opts.password ? { @@ -142,19 +154,39 @@ export class GatewayBrowserClient { password: this.opts.password, } : undefined; - const signedAtMs = Date.now(); - const nonce = this.connectNonce ?? undefined; - const payload = buildDeviceAuthPayload({ - deviceId: deviceIdentity.deviceId, - clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, - role, - scopes, - signedAtMs, - token: authToken ?? null, - nonce, - }); - const signature = await signDevicePayload(deviceIdentity.privateKey, payload); + + let device: + | { + id: string; + publicKey: string; + signature: string; + signedAt: number; + nonce: string | undefined; + } + | undefined; + + if (isSecureContext && deviceIdentity) { + const signedAtMs = Date.now(); + const nonce = this.connectNonce ?? undefined; + const payload = buildDeviceAuthPayload({ + deviceId: deviceIdentity.deviceId, + clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + role, + scopes, + signedAtMs, + token: authToken ?? null, + nonce, + }); + const signature = await signDevicePayload(deviceIdentity.privateKey, payload); + device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKey, + signature, + signedAt: signedAtMs, + nonce, + }; + } const params = { minProtocol: 3, maxProtocol: 3, @@ -167,13 +199,7 @@ export class GatewayBrowserClient { }, role, scopes, - device: { - id: deviceIdentity.deviceId, - publicKey: deviceIdentity.publicKey, - signature, - signedAt: signedAtMs, - nonce, - }, + device, caps: [], auth, userAgent: navigator.userAgent, @@ -182,7 +208,7 @@ export class GatewayBrowserClient { void this.request("connect", params) .then((hello) => { - if (hello?.auth?.deviceToken) { + if (hello?.auth?.deviceToken && deviceIdentity) { storeDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role: hello.auth.role ?? role, @@ -194,7 +220,7 @@ export class GatewayBrowserClient { this.opts.onHello?.(hello); }) .catch(() => { - if (canFallbackToShared) { + if (canFallbackToShared && deviceIdentity) { clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); } this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index eb7e81948..6b51ed811 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -77,6 +77,44 @@ export function renderOverview(props: OverviewProps) { `; })(); + const insecureContextHint = (() => { + if (props.connected || !props.lastError) return null; + const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true; + if (isSecureContext !== false) return null; + const lower = props.lastError.toLowerCase(); + if (!lower.includes("secure context") && !lower.includes("device identity required")) { + return null; + } + return html` +
+ This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or + open http://127.0.0.1:18789 on the gateway host. +
+ If you must stay on HTTP, set + gateway.controlUi.allowInsecureAuth: true (token-only). +
+ +
+ `; + })(); return html`
@@ -167,6 +205,7 @@ export function renderOverview(props: OverviewProps) { ? html`
${props.lastError}
${authHint ?? ""} + ${insecureContextHint ?? ""}
` : html`
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.