feat: add control ui device auth bypass
This commit is contained in:
@@ -9,6 +9,7 @@ Status: unreleased.
|
||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||
- 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: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
||||
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
||||
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
||||
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
|
||||
|
||||
@@ -2847,9 +2847,11 @@ 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 for the Control UI and skips
|
||||
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
|
||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI when
|
||||
device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS
|
||||
(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:
|
||||
- [Control UI](/web/control-ui)
|
||||
|
||||
@@ -198,7 +198,8 @@ 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.
|
||||
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.
|
||||
|
||||
## TLS + pinning
|
||||
|
||||
@@ -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
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
## Reverse Proxy Configuration
|
||||
|
||||
@@ -199,6 +199,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"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.reload.mode": "Config Reload Mode",
|
||||
"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).",
|
||||
"gateway.controlUi.allowInsecureAuth":
|
||||
"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":
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
|
||||
@@ -66,6 +66,8 @@ export type GatewayControlUiConfig = {
|
||||
basePath?: string;
|
||||
/** Allow token-only auth over insecure HTTP (default: false). */
|
||||
allowInsecureAuth?: boolean;
|
||||
/** DANGEROUS: Disable device identity checks for the Control UI (default: false). */
|
||||
dangerouslyDisableDeviceAuth?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password";
|
||||
|
||||
@@ -319,6 +319,7 @@ export const ClawdbotSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
basePath: z.string().optional(),
|
||||
allowInsecureAuth: z.boolean().optional(),
|
||||
dangerouslyDisableDeviceAuth: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -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 () => {
|
||||
testState.gatewayAuth = { mode: "none" };
|
||||
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
|
||||
@@ -335,7 +335,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectParams.role = role;
|
||||
connectParams.scopes = scopes;
|
||||
|
||||
const device = connectParams.device;
|
||||
const deviceRaw = connectParams.device;
|
||||
let devicePublicKey: string | null = null;
|
||||
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||
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 allowInsecureControlUi =
|
||||
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") {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("proxy-auth-required", {
|
||||
@@ -370,9 +374,9 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
|
||||
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)";
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("control-ui-insecure-auth", {
|
||||
@@ -615,7 +619,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipPairing = allowInsecureControlUi && hasSharedAuth;
|
||||
const skipPairing = allowControlUiBypass && hasSharedAuth;
|
||||
if (device && devicePublicKey && !skipPairing) {
|
||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||
const pairing = await requestDevicePairing({
|
||||
@@ -736,9 +740,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
||||
const clientId = connectParams.client.id;
|
||||
const instanceId = connectParams.client.instanceId;
|
||||
const presenceKey = shouldTrackPresence
|
||||
? (connectParams.device?.id ?? instanceId ?? connId)
|
||||
: undefined;
|
||||
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
|
||||
|
||||
logWs("in", "connect", {
|
||||
connId,
|
||||
@@ -766,10 +768,10 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
modelIdentifier: connectParams.client.modelIdentifier,
|
||||
mode: connectParams.client.mode,
|
||||
deviceId: connectParams.device?.id,
|
||||
deviceId: device?.id,
|
||||
roles: [role],
|
||||
scopes,
|
||||
instanceId: connectParams.device?.id ?? instanceId,
|
||||
instanceId: device?.id ?? instanceId,
|
||||
reason: "connect",
|
||||
});
|
||||
incrementPresenceVersion();
|
||||
|
||||
@@ -293,7 +293,30 @@ describe("security audit", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -274,7 +274,7 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
||||
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
|
||||
findings.push({
|
||||
checkId: "gateway.control_ui.insecure_auth",
|
||||
severity: "warn",
|
||||
severity: "critical",
|
||||
title: "Control UI allows insecure HTTP auth",
|
||||
detail:
|
||||
"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 =
|
||||
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
||||
if (auth.mode === "token" && token && token.length < 24) {
|
||||
|
||||
Reference in New Issue
Block a user