fix: allow control ui token auth without pairing
This commit is contained in:
@@ -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
|
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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: 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.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
|
|||||||
@@ -2847,8 +2847,9 @@ Control UI base path:
|
|||||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||||
- Default: root (`/`) (unchanged).
|
- Default: root (`/`) (unchanged).
|
||||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
|
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
|
||||||
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
|
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
|
||||||
|
(Tailscale Serve) or `127.0.0.1`.
|
||||||
|
|
||||||
Related docs:
|
Related docs:
|
||||||
- [Control UI](/web/control-ui)
|
- [Control UI](/web/control-ui)
|
||||||
|
|||||||
@@ -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
|
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||||
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
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`.
|
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||||
|
|
||||||
`clawdbot security audit` warns when this setting is enabled.
|
`clawdbot security audit` warns when this setting is enabled.
|
||||||
|
|||||||
@@ -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
|
This disables device identity + pairing for the Control UI (even on HTTPS). Use
|
||||||
trust the network.
|
only if you trust the network.
|
||||||
|
|
||||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
||||||
|
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||||
import {
|
import {
|
||||||
connectReq,
|
connectReq,
|
||||||
getFreePort,
|
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<void>((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 () => {
|
test("accepts device token auth for paired device", async () => {
|
||||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||||
|
|||||||
@@ -318,13 +318,13 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
let devicePublicKey: string | null = null;
|
let devicePublicKey: string | null = null;
|
||||||
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||||
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
||||||
|
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
|
||||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||||
|
const allowInsecureControlUi =
|
||||||
|
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
const allowInsecureControlUi =
|
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
|
||||||
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
|
||||||
const canSkipDevice =
|
|
||||||
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
|
||||||
|
|
||||||
if (isControlUi && !allowInsecureControlUi) {
|
if (isControlUi && !allowInsecureControlUi) {
|
||||||
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||||
@@ -569,7 +569,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (device && devicePublicKey) {
|
const skipPairing = allowInsecureControlUi && hasSharedAuth;
|
||||||
|
if (device && devicePublicKey && !skipPairing) {
|
||||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user