Merge branch 'clawdbot:main' into feature/memoryflush-session-reset
This commit is contained in:
commit
50f924da4b
@ -6,8 +6,10 @@ Docs: https://docs.clawd.bot
|
||||
Status: unreleased.
|
||||
|
||||
### Changes
|
||||
- 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.
|
||||
@ -50,6 +52,7 @@ Status: unreleased.
|
||||
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
|
||||
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
|
||||
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||
|
||||
## 2026.1.24-3
|
||||
|
||||
|
||||
@ -314,7 +314,7 @@ Options:
|
||||
- `--opencode-zen-api-key <key>`
|
||||
- `--gateway-port <port>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
||||
- `--gateway-auth <off|token|password>`
|
||||
- `--gateway-auth <token|password>`
|
||||
- `--gateway-token <token>`
|
||||
- `--gateway-password <password>`
|
||||
- `--remote-url <url>`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -214,7 +214,7 @@ the Gateway likely refused to bind.
|
||||
- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite).
|
||||
|
||||
**If `Last gateway error:` mentions “refusing to bind … without auth”**
|
||||
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off.
|
||||
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but didn’t configure auth.
|
||||
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service.
|
||||
|
||||
**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found**
|
||||
|
||||
78
src/agents/pi-tools.safe-bins.test.ts
Normal file
78
src/agents/pi-tools.safe-bins.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
|
||||
const approvals: ExecApprovalsResolved = {
|
||||
path: "/tmp/exec-approvals.json",
|
||||
socketPath: "/tmp/exec-approvals.sock",
|
||||
token: "token",
|
||||
defaults: {
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agent: {
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
allowlist: [],
|
||||
file: {
|
||||
version: 1,
|
||||
socket: { path: "/tmp/exec-approvals.sock", token: "token" },
|
||||
defaults: {
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agents: {},
|
||||
},
|
||||
};
|
||||
return { ...mod, resolveExecApprovals: () => approvals };
|
||||
});
|
||||
|
||||
describe("createClawdbotCodingTools safeBins", () => {
|
||||
it("threads tools.exec.safeBins into exec allowlist checks", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-safe-bins-"));
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
safeBins: ["echo"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: tmpDir,
|
||||
agentDir: path.join(tmpDir, "agent"),
|
||||
});
|
||||
const execTool = tools.find((tool) => tool.name === "exec");
|
||||
expect(execTool).toBeDefined();
|
||||
|
||||
const marker = `safe-bins-${Date.now()}`;
|
||||
const result = await execTool!.execute("call1", {
|
||||
command: `echo ${marker}`,
|
||||
workdir: tmpDir,
|
||||
});
|
||||
const text = result.content.find((content) => content.type === "text")?.text ?? "";
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(text).toContain(marker);
|
||||
});
|
||||
});
|
||||
@ -86,6 +86,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
|
||||
ask: globalExec?.ask,
|
||||
node: globalExec?.node,
|
||||
pathPrepend: globalExec?.pathPrepend,
|
||||
safeBins: globalExec?.safeBins,
|
||||
backgroundMs: globalExec?.backgroundMs,
|
||||
timeoutSec: globalExec?.timeoutSec,
|
||||
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
|
||||
@ -235,6 +236,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
ask: options?.exec?.ask ?? execConfig.ask,
|
||||
node: options?.exec?.node ?? execConfig.node,
|
||||
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
||||
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
|
||||
agentId,
|
||||
cwd: options?.workspaceDir,
|
||||
allowBackground,
|
||||
|
||||
@ -78,7 +78,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: token|password")
|
||||
.option("--gateway-token <token>", "Gateway token (token auth)")
|
||||
.option("--gateway-password <password>", "Gateway password (password auth)")
|
||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||
|
||||
@ -3,26 +3,18 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildGatewayAuthConfig } from "./configure.js";
|
||||
|
||||
describe("buildGatewayAuthConfig", () => {
|
||||
it("clears token/password when auth is off", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
existing: { mode: "token", token: "abc", password: "secret" },
|
||||
mode: "off",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves allowTailscale when auth is off", () => {
|
||||
it("preserves allowTailscale when switching to token", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
existing: {
|
||||
mode: "token",
|
||||
token: "abc",
|
||||
mode: "password",
|
||||
password: "secret",
|
||||
allowTailscale: true,
|
||||
},
|
||||
mode: "off",
|
||||
mode: "token",
|
||||
token: "abc",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allowTailscale: true });
|
||||
expect(result).toEqual({ mode: "token", token: "abc", allowTailscale: true });
|
||||
});
|
||||
|
||||
it("drops password when switching to token", () => {
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
promptModelAllowlist,
|
||||
} from "./model-picker.js";
|
||||
|
||||
type GatewayAuthChoice = "off" | "token" | "password";
|
||||
type GatewayAuthChoice = "token" | "password";
|
||||
|
||||
const ANTHROPIC_OAUTH_MODEL_KEYS = [
|
||||
"anthropic/claude-opus-4-5",
|
||||
@ -30,9 +30,6 @@ export function buildGatewayAuthConfig(params: {
|
||||
const base: GatewayAuthConfig = {};
|
||||
if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale;
|
||||
|
||||
if (params.mode === "off") {
|
||||
return Object.keys(base).length > 0 ? base : undefined;
|
||||
}
|
||||
if (params.mode === "token") {
|
||||
return { ...base, mode: "token", token: params.token };
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
|
||||
import { confirm, select, text } from "./configure.shared.js";
|
||||
import { guardCancel, randomToken } from "./onboard-helpers.js";
|
||||
|
||||
type GatewayAuthChoice = "off" | "token" | "password";
|
||||
type GatewayAuthChoice = "token" | "password";
|
||||
|
||||
export async function promptGatewayConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
@ -91,11 +91,6 @@ export async function promptGatewayConfig(
|
||||
await select({
|
||||
message: "Gateway auth",
|
||||
options: [
|
||||
{
|
||||
value: "off",
|
||||
label: "Off (loopback only)",
|
||||
hint: "Not recommended unless you fully trust local processes",
|
||||
},
|
||||
{ value: "token", label: "Token", hint: "Recommended default" },
|
||||
{ value: "password", label: "Password" },
|
||||
],
|
||||
@ -165,11 +160,6 @@ export async function promptGatewayConfig(
|
||||
bind = "loopback";
|
||||
}
|
||||
|
||||
if (authMode === "off" && bind !== "loopback") {
|
||||
note("Non-loopback bind requires auth. Switching to token auth.", "Note");
|
||||
authMode = "token";
|
||||
}
|
||||
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||
note("Tailscale funnel requires password auth.", "Note");
|
||||
authMode = "password";
|
||||
|
||||
@ -210,7 +210,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
it("auto-enables token auth when binding LAN and persists the token", async () => {
|
||||
it("auto-generates token auth when binding LAN and persists the token", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
||||
return;
|
||||
@ -242,7 +242,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
installDaemon: false,
|
||||
gatewayPort: port,
|
||||
gatewayBind: "lan",
|
||||
gatewayAuth: "off",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
@ -28,16 +28,20 @@ export function applyNonInteractiveGatewayConfig(params: {
|
||||
|
||||
const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort;
|
||||
let bind = opts.gatewayBind ?? "loopback";
|
||||
let authMode = opts.gatewayAuth ?? "token";
|
||||
const authModeRaw = opts.gatewayAuth ?? "token";
|
||||
if (authModeRaw !== "token" && authModeRaw !== "password") {
|
||||
runtime.error("Invalid --gateway-auth (use token|password).");
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
let authMode = authModeRaw;
|
||||
const tailscaleMode = opts.tailscale ?? "off";
|
||||
const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit);
|
||||
|
||||
// Tighten config to safe combos:
|
||||
// - If Tailscale is on, force loopback bind (the tunnel handles external access).
|
||||
// - If binding beyond loopback, disallow auth=off.
|
||||
// - If using Tailscale Funnel, require password auth.
|
||||
if (tailscaleMode !== "off" && bind !== "loopback") bind = "loopback";
|
||||
if (authMode === "off" && bind !== "loopback") authMode = "token";
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") authMode = "password";
|
||||
|
||||
let nextConfig = params.nextConfig;
|
||||
|
||||
@ -32,7 +32,7 @@ export type AuthChoice =
|
||||
| "copilot-proxy"
|
||||
| "qwen-portal"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type GatewayAuthChoice = "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
|
||||
export type TailscaleMode = "off" | "serve" | "funnel";
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -93,11 +93,6 @@ export async function configureGatewayForOnboarding(
|
||||
: ((await prompter.select({
|
||||
message: "Gateway auth",
|
||||
options: [
|
||||
{
|
||||
value: "off",
|
||||
label: "Off (loopback only)",
|
||||
hint: "Not recommended unless you fully trust local processes",
|
||||
},
|
||||
{
|
||||
value: "token",
|
||||
label: "Token",
|
||||
@ -165,7 +160,6 @@ export async function configureGatewayForOnboarding(
|
||||
|
||||
// Safety + constraints:
|
||||
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
|
||||
// - Auth off only allowed for bind=loopback.
|
||||
// - Funnel requires password auth.
|
||||
if (tailscaleMode !== "off" && bind !== "loopback") {
|
||||
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
|
||||
@ -173,11 +167,6 @@ export async function configureGatewayForOnboarding(
|
||||
customBindHost = undefined;
|
||||
}
|
||||
|
||||
if (authMode === "off" && bind !== "loopback") {
|
||||
await prompter.note("Non-loopback bind requires auth. Switching to token auth.", "Note");
|
||||
authMode = "token";
|
||||
}
|
||||
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||
await prompter.note("Tailscale funnel requires password auth.", "Note");
|
||||
authMode = "password";
|
||||
|
||||
@ -244,7 +244,6 @@ export async function runOnboardingWizard(
|
||||
return "Auto";
|
||||
};
|
||||
const formatAuth = (value: GatewayAuthChoice) => {
|
||||
if (value === "off") return "Off (loopback only)";
|
||||
if (value === "token") return "Token (default)";
|
||||
return "Password";
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user