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)
|
- 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
- **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.
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).',
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user