fix: enforce secure control ui auth
This commit is contained in:
@@ -14,6 +14,9 @@ Docs: https://docs.clawd.bot
|
|||||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
- 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.
|
- 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
|
### Fixes
|
||||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
- 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.
|
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||||
|
|||||||
@@ -2671,6 +2671,8 @@ 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).
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
|
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||||
(so same‑host tailnet binds can still auto‑approve).
|
(so same‑host tailnet binds can still auto‑approve).
|
||||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
- 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.
|
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||||
|
|
||||||
## TLS + pinning
|
## TLS + pinning
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
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
|
## Local session logs live on disk
|
||||||
|
|
||||||
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
|||||||
|
|
||||||
## Common Issues
|
## Common Issues
|
||||||
|
|
||||||
|
### Control UI fails on HTTP ("device identity required" / "connect failed")
|
||||||
|
|
||||||
|
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
|
||||||
|
`http://<tailscale-ip>: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
|
### CI Secrets Scan Failed
|
||||||
|
|
||||||
This means `detect-secrets` found new candidates not yet in the baseline.
|
This means `detect-secrets` found new candidates not yet in the baseline.
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ Almost always a Node/npm PATH issue. Start here:
|
|||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
- [Gateway authentication](/gateway/authentication)
|
- [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
|
### Service says running, but RPC probe fails
|
||||||
|
|
||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
|
|||||||
@@ -86,6 +86,33 @@ Then open:
|
|||||||
|
|
||||||
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
||||||
|
|
||||||
|
## Insecure HTTP
|
||||||
|
|
||||||
|
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`),
|
||||||
|
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://<magicdns>/` (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
|
## Building the UI
|
||||||
|
|
||||||
The Gateway serves static files from `dist/control-ui`. Build them with:
|
The Gateway serves static files from `dist/control-ui`. Build them with:
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
"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.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||||
"gateway.reload.mode": "Config Reload Mode",
|
"gateway.reload.mode": "Config Reload Mode",
|
||||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||||
@@ -345,6 +346,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
"gateway.controlUi.basePath":
|
"gateway.controlUi.basePath":
|
||||||
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
"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":
|
"gateway.http.endpoints.chatCompletions.enabled":
|
||||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export type GatewayControlUiConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
|
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
/** Allow token-only auth over insecure HTTP (default: false). */
|
||||||
|
allowInsecureAuth?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayAuthMode = "token" | "password";
|
export type GatewayAuthMode = "token" | "password";
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ export const ClawdbotSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
basePath: z.string().optional(),
|
basePath: z.string().optional(),
|
||||||
|
allowInsecureAuth: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
testState,
|
testState,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
@@ -127,6 +128,52 @@ describe("gateway server auth/connect", () => {
|
|||||||
await server.close();
|
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 () => {
|
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 } =
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
validateConnectParams,
|
validateConnectParams,
|
||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
} from "../../protocol/index.js";
|
} 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 { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
||||||
import { handleGatewayRequest } from "../../server-methods.js";
|
import { handleGatewayRequest } from "../../server-methods.js";
|
||||||
@@ -293,24 +294,53 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
|
|
||||||
const device = connectParams.device;
|
const device = connectParams.device;
|
||||||
let devicePublicKey: string | null = null;
|
let devicePublicKey: string | null = null;
|
||||||
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
|
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||||
const hasTokenAuth = !!connectParams.auth?.token;
|
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
||||||
if (!device && !hasTokenAuth) {
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||||
setHandshakeState("failed");
|
|
||||||
setCloseCause("device-required", {
|
if (!device) {
|
||||||
client: connectParams.client.id,
|
const allowInsecureControlUi =
|
||||||
clientDisplayName: connectParams.client.displayName,
|
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
|
||||||
mode: connectParams.client.mode,
|
const canSkipDevice =
|
||||||
version: connectParams.client.version,
|
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
||||||
});
|
|
||||||
send({
|
if (isControlUi && !allowInsecureControlUi) {
|
||||||
type: "res",
|
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||||
id: frame.id,
|
setHandshakeState("failed");
|
||||||
ok: false,
|
setCloseCause("control-ui-insecure-auth", {
|
||||||
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
|
client: connectParams.client.id,
|
||||||
});
|
clientDisplayName: connectParams.client.displayName,
|
||||||
close(1008, "device identity required");
|
mode: connectParams.client.mode,
|
||||||
return;
|
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) {
|
if (device) {
|
||||||
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export const testState = {
|
|||||||
cronEnabled: false as boolean | undefined,
|
cronEnabled: false as boolean | undefined,
|
||||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
||||||
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
||||||
|
gatewayControlUi: undefined as Record<string, unknown> | undefined,
|
||||||
hooksConfig: undefined as HooksConfig | undefined,
|
hooksConfig: undefined as HooksConfig | undefined,
|
||||||
canvasHostPort: undefined as number | undefined,
|
canvasHostPort: undefined as number | undefined,
|
||||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
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.gatewayBind) fileGateway.bind = testState.gatewayBind;
|
||||||
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
|
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
|
||||||
|
if (testState.gatewayControlUi) fileGateway.controlUi = testState.gatewayControlUi;
|
||||||
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
|
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
|
||||||
|
|
||||||
const fileCanvasHost =
|
const fileCanvasHost =
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function installGatewayTestHooks() {
|
|||||||
testTailnetIPv4.value = undefined;
|
testTailnetIPv4.value = undefined;
|
||||||
testState.gatewayBind = undefined;
|
testState.gatewayBind = undefined;
|
||||||
testState.gatewayAuth = undefined;
|
testState.gatewayAuth = undefined;
|
||||||
|
testState.gatewayControlUi = undefined;
|
||||||
testState.hooksConfig = undefined;
|
testState.hooksConfig = undefined;
|
||||||
testState.canvasHostPort = undefined;
|
testState.canvasHostPort = undefined;
|
||||||
testState.legacyIssues = [];
|
testState.legacyIssues = [];
|
||||||
@@ -280,7 +281,7 @@ export async function connectReq(
|
|||||||
signature: string;
|
signature: string;
|
||||||
signedAt: number;
|
signedAt: number;
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
};
|
} | null;
|
||||||
},
|
},
|
||||||
): Promise<ConnectResponse> {
|
): Promise<ConnectResponse> {
|
||||||
const { randomUUID } = await import("node:crypto");
|
const { randomUUID } = await import("node:crypto");
|
||||||
@@ -294,6 +295,7 @@ export async function connectReq(
|
|||||||
const role = opts?.role ?? "operator";
|
const role = opts?.role ?? "operator";
|
||||||
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
||||||
const device = (() => {
|
const device = (() => {
|
||||||
|
if (opts?.device === null) return undefined;
|
||||||
if (opts?.device) return opts.device;
|
if (opts?.device) return opts.device;
|
||||||
const identity = loadOrCreateDeviceIdentity();
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
const signedAtMs = Date.now();
|
const signedAtMs = Date.now();
|
||||||
|
|||||||
@@ -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 () => {
|
it("warns when multiple DM senders share the main session", async () => {
|
||||||
const cfg: ClawdbotConfig = { session: { dmScope: "main" } };
|
const cfg: ClawdbotConfig = { session: { dmScope: "main" } };
|
||||||
const plugins: ChannelPlugin[] = [
|
const plugins: ChannelPlugin[] = [
|
||||||
|
|||||||
@@ -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 =
|
const token =
|
||||||
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
||||||
if (auth.mode === "token" && token && token.length < 24) {
|
if (auth.mode === "token" && token && token.length < 24) {
|
||||||
|
|||||||
@@ -126,15 +126,27 @@ export class GatewayBrowserClient {
|
|||||||
window.clearTimeout(this.connectTimer);
|
window.clearTimeout(this.connectTimer);
|
||||||
this.connectTimer = null;
|
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 scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||||
const role = "operator";
|
const role = "operator";
|
||||||
const storedToken = loadDeviceAuthToken({
|
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||||
deviceId: deviceIdentity.deviceId,
|
let canFallbackToShared = false;
|
||||||
role,
|
let authToken = this.opts.token;
|
||||||
})?.token;
|
|
||||||
const authToken = storedToken ?? this.opts.token;
|
if (isSecureContext) {
|
||||||
const canFallbackToShared = Boolean(storedToken && this.opts.token);
|
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||||
|
const storedToken = loadDeviceAuthToken({
|
||||||
|
deviceId: deviceIdentity.deviceId,
|
||||||
|
role,
|
||||||
|
})?.token;
|
||||||
|
authToken = storedToken ?? this.opts.token;
|
||||||
|
canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||||
|
}
|
||||||
const auth =
|
const auth =
|
||||||
authToken || this.opts.password
|
authToken || this.opts.password
|
||||||
? {
|
? {
|
||||||
@@ -142,19 +154,39 @@ export class GatewayBrowserClient {
|
|||||||
password: this.opts.password,
|
password: this.opts.password,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const signedAtMs = Date.now();
|
|
||||||
const nonce = this.connectNonce ?? undefined;
|
let device:
|
||||||
const payload = buildDeviceAuthPayload({
|
| {
|
||||||
deviceId: deviceIdentity.deviceId,
|
id: string;
|
||||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
publicKey: string;
|
||||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
signature: string;
|
||||||
role,
|
signedAt: number;
|
||||||
scopes,
|
nonce: string | undefined;
|
||||||
signedAtMs,
|
}
|
||||||
token: authToken ?? null,
|
| undefined;
|
||||||
nonce,
|
|
||||||
});
|
if (isSecureContext && deviceIdentity) {
|
||||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
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 = {
|
const params = {
|
||||||
minProtocol: 3,
|
minProtocol: 3,
|
||||||
maxProtocol: 3,
|
maxProtocol: 3,
|
||||||
@@ -167,13 +199,7 @@ export class GatewayBrowserClient {
|
|||||||
},
|
},
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
device: {
|
device,
|
||||||
id: deviceIdentity.deviceId,
|
|
||||||
publicKey: deviceIdentity.publicKey,
|
|
||||||
signature,
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
nonce,
|
|
||||||
},
|
|
||||||
caps: [],
|
caps: [],
|
||||||
auth,
|
auth,
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
@@ -182,7 +208,7 @@ export class GatewayBrowserClient {
|
|||||||
|
|
||||||
void this.request<GatewayHelloOk>("connect", params)
|
void this.request<GatewayHelloOk>("connect", params)
|
||||||
.then((hello) => {
|
.then((hello) => {
|
||||||
if (hello?.auth?.deviceToken) {
|
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||||
storeDeviceAuthToken({
|
storeDeviceAuthToken({
|
||||||
deviceId: deviceIdentity.deviceId,
|
deviceId: deviceIdentity.deviceId,
|
||||||
role: hello.auth.role ?? role,
|
role: hello.auth.role ?? role,
|
||||||
@@ -194,7 +220,7 @@ export class GatewayBrowserClient {
|
|||||||
this.opts.onHello?.(hello);
|
this.opts.onHello?.(hello);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (canFallbackToShared) {
|
if (canFallbackToShared && deviceIdentity) {
|
||||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||||
}
|
}
|
||||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||||
|
|||||||
@@ -77,6 +77,44 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})();
|
})();
|
||||||
|
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`
|
||||||
|
<div class="muted" style="margin-top: 8px;">
|
||||||
|
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
|
||||||
|
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
|
||||||
|
<div style="margin-top: 6px;">
|
||||||
|
If you must stay on HTTP, set
|
||||||
|
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 6px;">
|
||||||
|
<a
|
||||||
|
class="session-link"
|
||||||
|
href="https://docs.clawd.bot/gateway/tailscale"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Tailscale Serve docs (opens in new tab)"
|
||||||
|
>Docs: Tailscale Serve</a
|
||||||
|
>
|
||||||
|
<span class="muted"> · </span>
|
||||||
|
<a
|
||||||
|
class="session-link"
|
||||||
|
href="https://docs.clawd.bot/web/control-ui#insecure-http"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Insecure HTTP docs (opens in new tab)"
|
||||||
|
>Docs: Insecure HTTP</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="grid grid-cols-2">
|
<section class="grid grid-cols-2">
|
||||||
@@ -167,6 +205,7 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
? html`<div class="callout danger" style="margin-top: 14px;">
|
? html`<div class="callout danger" style="margin-top: 14px;">
|
||||||
<div>${props.lastError}</div>
|
<div>${props.lastError}</div>
|
||||||
${authHint ?? ""}
|
${authHint ?? ""}
|
||||||
|
${insecureContextHint ?? ""}
|
||||||
</div>`
|
</div>`
|
||||||
: html`<div class="callout" style="margin-top: 14px;">
|
: html`<div class="callout" style="margin-top: 14px;">
|
||||||
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||||
|
|||||||
Reference in New Issue
Block a user