feat: add control ui device auth bypass

This commit is contained in:
Peter Steinberger
2026-01-26 17:40:24 +00:00
parent a486940781
commit e6bdffe568
11 changed files with 112 additions and 15 deletions

View File

@@ -9,6 +9,7 @@ Status: unreleased.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.

View File

@@ -2847,9 +2847,11 @@ 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 for the Control UI and skips - `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI when
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS
(Tailscale Serve) or `127.0.0.1`. (Tailscale Serve) or `127.0.0.1`.
- `gateway.controlUi.dangerouslyDisableDeviceAuth` disables device identity checks for the
Control UI (token/password only). Default: `false`. Break-glass only.
Related docs: Related docs:
- [Control UI](/web/control-ui) - [Control UI](/web/control-ui)

View File

@@ -198,7 +198,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway hosts own tailnet address - **Local** connects include loopback and the gateway hosts own tailnet address
(so samehost tailnet binds can still autoapprove). (so samehost tailnet binds can still autoapprove).
- 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. Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled
(or `gateway.controlUi.dangerouslyDisableDeviceAuth` for break-glass use).
- 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

View File

@@ -58,9 +58,13 @@ 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** and skips device pairing (even on HTTPS). This is a security to **token-only auth** and skips device pairing when device identity is omitted. 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`.
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
disables device identity checks entirely. This is a severe security downgrade;
keep it off unless you are actively debugging and can revert quickly.
`clawdbot security audit` warns when this setting is enabled. `clawdbot security audit` warns when this setting is enabled.
## Reverse Proxy Configuration ## Reverse Proxy Configuration

View File

@@ -199,6 +199,7 @@ const FIELD_LABELS: Record<string, string> = {
"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.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device 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)",
@@ -381,6 +382,8 @@ const FIELD_HELP: Record<string, string> = {
"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": "gateway.controlUi.allowInsecureAuth":
"Allow Control UI auth over insecure HTTP (token-only; not recommended).", "Allow Control UI auth over insecure HTTP (token-only; not recommended).",
"gateway.controlUi.dangerouslyDisableDeviceAuth":
"DANGEROUS. Disable Control UI device identity checks (token/password only).",
"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).',

View File

@@ -66,6 +66,8 @@ export type GatewayControlUiConfig = {
basePath?: string; basePath?: string;
/** Allow token-only auth over insecure HTTP (default: false). */ /** Allow token-only auth over insecure HTTP (default: false). */
allowInsecureAuth?: boolean; allowInsecureAuth?: boolean;
/** DANGEROUS: Disable device identity checks for the Control UI (default: false). */
dangerouslyDisableDeviceAuth?: boolean;
}; };
export type GatewayAuthMode = "token" | "password"; export type GatewayAuthMode = "token" | "password";

View File

@@ -319,6 +319,7 @@ export const ClawdbotSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
basePath: z.string().optional(), basePath: z.string().optional(),
allowInsecureAuth: z.boolean().optional(), allowInsecureAuth: z.boolean().optional(),
dangerouslyDisableDeviceAuth: z.boolean().optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@@ -352,6 +352,53 @@ describe("gateway server auth/connect", () => {
} }
}); });
test("allows control ui with stale device identity when device auth is disabled", async () => {
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = await openWs(port);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now() - 60 * 60 * 1000;
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
role: "operator",
scopes: [],
signedAtMs,
token: "secret",
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
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);
expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("rejects proxied connections without auth when proxy headers are untrusted", async () => { test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
testState.gatewayAuth = { mode: "none" }; testState.gatewayAuth = { mode: "none" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;

View File

@@ -335,7 +335,7 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role; connectParams.role = role;
connectParams.scopes = scopes; connectParams.scopes = scopes;
const device = connectParams.device; const deviceRaw = connectParams.device;
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);
@@ -343,6 +343,10 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const allowInsecureControlUi = const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
const disableControlUiDeviceAuth =
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
const device = disableControlUiDeviceAuth ? null : deviceRaw;
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") { if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
setHandshakeState("failed"); setHandshakeState("failed");
setCloseCause("proxy-auth-required", { setCloseCause("proxy-auth-required", {
@@ -370,9 +374,9 @@ export function attachGatewayWsMessageHandler(params: {
} }
if (!device) { if (!device) {
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth; const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
if (isControlUi && !allowInsecureControlUi) { if (isControlUi && !allowControlUiBypass) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)"; const errorMessage = "control ui requires HTTPS or localhost (secure context)";
setHandshakeState("failed"); setHandshakeState("failed");
setCloseCause("control-ui-insecure-auth", { setCloseCause("control-ui-insecure-auth", {
@@ -615,7 +619,7 @@ export function attachGatewayWsMessageHandler(params: {
return; return;
} }
const skipPairing = allowInsecureControlUi && hasSharedAuth; const skipPairing = allowControlUiBypass && hasSharedAuth;
if (device && devicePublicKey && !skipPairing) { 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({
@@ -736,9 +740,7 @@ export function attachGatewayWsMessageHandler(params: {
const shouldTrackPresence = !isGatewayCliClient(connectParams.client); const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id; const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId; const instanceId = connectParams.client.instanceId;
const presenceKey = shouldTrackPresence const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
? (connectParams.device?.id ?? instanceId ?? connId)
: undefined;
logWs("in", "connect", { logWs("in", "connect", {
connId, connId,
@@ -766,10 +768,10 @@ export function attachGatewayWsMessageHandler(params: {
deviceFamily: connectParams.client.deviceFamily, deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier, modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode, mode: connectParams.client.mode,
deviceId: connectParams.device?.id, deviceId: device?.id,
roles: [role], roles: [role],
scopes, scopes,
instanceId: connectParams.device?.id ?? instanceId, instanceId: device?.id ?? instanceId,
reason: "connect", reason: "connect",
}); });
incrementPresenceVersion(); incrementPresenceVersion();

View File

@@ -293,7 +293,30 @@ describe("security audit", () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
checkId: "gateway.control_ui.insecure_auth", checkId: "gateway.control_ui.insecure_auth",
severity: "warn", severity: "critical",
}),
]),
);
});
it("warns when control UI device auth is disabled", async () => {
const cfg: ClawdbotConfig = {
gateway: {
controlUi: { dangerouslyDisableDeviceAuth: true },
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.control_ui.device_auth_disabled",
severity: "critical",
}), }),
]), ]),
); );

View File

@@ -274,7 +274,7 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
findings.push({ findings.push({
checkId: "gateway.control_ui.insecure_auth", checkId: "gateway.control_ui.insecure_auth",
severity: "warn", severity: "critical",
title: "Control UI allows insecure HTTP auth", title: "Control UI allows insecure HTTP auth",
detail: detail:
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.", "gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
@@ -282,6 +282,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
}); });
} }
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
findings.push({
checkId: "gateway.control_ui.device_auth_disabled",
severity: "critical",
title: "DANGEROUS: Control UI device auth disabled",
detail:
"gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.",
remediation: "Disable it unless you are in a short-lived break-glass scenario.",
});
}
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) {