diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4570fc5..668a91823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Status: unreleased. ### Fixes - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. +- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). ## 2026.1.24-3 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 89fe7f784..97427debe 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2867,12 +2867,12 @@ Notes: - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. -- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- Gateway auth is required by default (token/password or Tailscale Serve identity). Non-loopback binds require a shared token/password. - The onboarding wizard generates a gateway token by default (even on loopback). - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored. Auth and Tailscale: -- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). +- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). When unset, token auth is assumed. - `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). diff --git a/docs/gateway/index.md b/docs/gateway/index.md index d37320d1b..824984bde 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -37,7 +37,7 @@ pnpm gateway:watch - `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing). - If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash. - **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts). -- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token ` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`. +- Gateway auth is required by default: set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) or `gateway.auth.password`. Clients must send `connect.params.auth.token/password` unless using Tailscale Serve identity. - The wizard now generates a token by default, even on loopback. - Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 1bdd014ba..d13d830cf 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -280,7 +280,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port: Bind mode controls where the Gateway listens: - `gateway.bind: "loopback"` (default): only local clients can connect. -- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall. +- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with a shared token/password and a real firewall. Rules of thumb: - Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access). @@ -289,13 +289,11 @@ Rules of thumb: ### 0.5) Lock down the Gateway WebSocket (local auth) -Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset, -loopback WS clients are unauthenticated — any local process can connect and call -`config.apply`. +Gateway auth is **required by default**. If no token/password is configured, +the Gateway refuses WebSocket connections (fail‑closed). -The onboarding wizard now generates a token by default (even for loopback) so -local clients must authenticate. If you skip the wizard or remove auth, you’re -back to open loopback. +The onboarding wizard generates a token by default (even for loopback) so +local clients must authenticate. Set a token so **all** WS clients must authenticate: diff --git a/docs/web/index.md b/docs/web/index.md index 82ca62205..0e1fadfa4 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -91,7 +91,8 @@ Open: ## Security notes -- Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`). +- Gateway auth is required by default (token/password or Tailscale identity headers). +- Non-loopback binds still **require** a shared token/password (`gateway.auth` or env). - The wizard generates a gateway token by default (even on loopback). - The UI sends `connect.params.auth.token` or `connect.params.auth.password`. - With Serve, Tailscale identity headers can satisfy auth when diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 2abfa67ea..3c968e0fc 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -16,7 +16,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. ## Quick start 1) Start the gateway. 2) Open the WebChat UI (macOS/iOS app) or the Control UI chat tab. -3) Ensure gateway auth is configured if you are not on loopback. +3) Ensure gateway auth is configured (required by default, even on loopback). ## How it works (behavior) - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 96437d566..002743170 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -249,7 +249,7 @@ describe("gateway-cli coverage", () => { programInvalidPort.exitOverride(); registerGatewayCli(programInvalidPort); await expect( - programInvalidPort.parseAsync(["gateway", "--port", "0"], { + programInvalidPort.parseAsync(["gateway", "--port", "0", "--token", "test-token"], { from: "user", }), ).rejects.toThrow("__exit__:1"); @@ -263,7 +263,7 @@ describe("gateway-cli coverage", () => { registerGatewayCli(programForceFail); await expect( programForceFail.parseAsync( - ["gateway", "--port", "18789", "--force", "--allow-unconfigured"], + ["gateway", "--port", "18789", "--token", "test-token", "--force", "--allow-unconfigured"], { from: "user" }, ), ).rejects.toThrow("__exit__:1"); @@ -276,9 +276,12 @@ describe("gateway-cli coverage", () => { const beforeSigterm = new Set(process.listeners("SIGTERM")); const beforeSigint = new Set(process.listeners("SIGINT")); await expect( - programStartFail.parseAsync(["gateway", "--port", "18789", "--allow-unconfigured"], { - from: "user", - }), + programStartFail.parseAsync( + ["gateway", "--port", "18789", "--token", "test-token", "--allow-unconfigured"], + { + from: "user", + }, + ), ).rejects.toThrow("__exit__:1"); for (const listener of process.listeners("SIGTERM")) { if (!beforeSigterm.has(listener)) process.removeListener("SIGTERM", listener); @@ -304,7 +307,7 @@ describe("gateway-cli coverage", () => { registerGatewayCli(program); await expect( - program.parseAsync(["gateway", "--allow-unconfigured"], { + program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], { from: "user", }), ).rejects.toThrow("__exit__:1"); @@ -327,7 +330,7 @@ describe("gateway-cli coverage", () => { startGatewayServer.mockRejectedValueOnce(new Error("nope")); await expect( - program.parseAsync(["gateway", "--allow-unconfigured"], { + program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], { from: "user", }), ).rejects.toThrow("__exit__:1"); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 1c2e8273c..0de667c3c 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -203,6 +203,10 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const resolvedAuthMode = resolvedAuth.mode; const tokenValue = resolvedAuth.token; const passwordValue = resolvedAuth.password; + const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0; + const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0; + const hasSharedSecret = + (resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword); const authHints: string[] = []; if (miskeys.hasGatewayToken) { authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.'); @@ -212,7 +216,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', ); } - if (resolvedAuthMode === "token" && !tokenValue) { + if (resolvedAuthMode === "token" && !hasToken && !resolvedAuth.allowTailscale) { defaultRuntime.error( [ "Gateway auth is set to token, but no token is configured.", @@ -225,7 +229,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } - if (resolvedAuthMode === "password" && !passwordValue) { + if (resolvedAuthMode === "password" && !hasPassword) { defaultRuntime.error( [ "Gateway auth is set to password, but no password is configured.", @@ -238,11 +242,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } - if (bind !== "loopback" && resolvedAuthMode === "none") { + if (bind !== "loopback" && !hasSharedSecret) { defaultRuntime.error( [ `Refusing to bind gateway to ${bind} without auth.`, - "Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.", + "Set gateway.auth.token/password (or CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_GATEWAY_PASSWORD) or pass --token/--password.", ...authHints, ] .filter(Boolean) diff --git a/src/config/schema.ts b/src/config/schema.ts index bb8d8c0bb..6cd6381ae 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -369,7 +369,8 @@ const FIELD_HELP: Record = { "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", "agents.list[].identity.avatar": "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", - "gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.", + "gateway.auth.token": + "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /clawdbot).", diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 0e0d1a7d5..f716be5dd 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -173,8 +173,7 @@ export function resolveGatewayAuth(params: { const env = params.env ?? process.env; const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; - const mode: ResolvedGatewayAuth["mode"] = - authConfig.mode ?? (password ? "password" : token ? "token" : "none"); + const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token"); const allowTailscale = authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password"); return { @@ -187,6 +186,7 @@ export function resolveGatewayAuth(params: { export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { if (auth.mode === "token" && !auth.token) { + if (auth.allowTailscale) return; throw new Error( "gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN)", ); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index a155c5d0a..2d699988a 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -70,6 +70,11 @@ export async function resolveGatewayRuntimeConfig(params: { tailscaleMode, }); const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode; + const hasToken = typeof resolvedAuth.token === "string" && resolvedAuth.token.trim().length > 0; + const hasPassword = + typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0; + const hasSharedSecret = + (authMode === "token" && hasToken) || (authMode === "password" && hasPassword); const hooksConfig = resolveHooksConfig(params.cfg); const canvasHostEnabled = process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false; @@ -83,9 +88,9 @@ export async function resolveGatewayRuntimeConfig(params: { if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) { throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)"); } - if (!isLoopbackHost(bindHost) && authMode === "none") { + if (!isLoopbackHost(bindHost) && !hasSharedSecret) { throw new Error( - `refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN, or pass --token)`, + `refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_GATEWAY_PASSWORD)`, ); } diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 17a8802b2..6474f285b 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -34,7 +34,7 @@ const openWs = async (port: number) => { }; describe("gateway server auth/connect", () => { - describe("default auth", () => { + describe("default auth (token)", () => { let server: Awaited>; let port: number; @@ -234,6 +234,7 @@ describe("gateway server auth/connect", () => { test("returns control ui hint when token is missing", async () => { const ws = await openWs(port); const res = await connectReq(ws, { + skipDefaultAuth: true, client: { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "1.0.0", @@ -352,6 +353,7 @@ describe("gateway server auth/connect", () => { }); test("rejects proxied connections without auth when proxy headers are untrusted", async () => { + testState.gatewayAuth = { mode: "none" }; const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN; const port = await getFreePort(); @@ -360,7 +362,7 @@ describe("gateway server auth/connect", () => { headers: { "x-forwarded-for": "203.0.113.10" }, }); await new Promise((resolve) => ws.once("open", resolve)); - const res = await connectReq(ws); + const res = await connectReq(ws, { skipDefaultAuth: true }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("gateway auth required"); ws.close(); diff --git a/src/gateway/server.nodes.late-invoke.test.ts b/src/gateway/server.nodes.late-invoke.test.ts index 50801583d..52f73e898 100644 --- a/src/gateway/server.nodes.late-invoke.test.ts +++ b/src/gateway/server.nodes.late-invoke.test.ts @@ -28,11 +28,12 @@ let ws: WebSocket; let port: number; beforeAll(async () => { - const started = await startServerWithClient(); + const token = "test-gateway-token-1234567890"; + const started = await startServerWithClient(token); server = started.server; ws = started.ws; port = started.port; - await connectOk(ws); + await connectOk(ws, { token }); }); afterAll(async () => { @@ -60,6 +61,7 @@ describe("late-arriving invoke results", () => { mode: GATEWAY_CLIENT_MODES.NODE, }, commands: ["canvas.snapshot"], + token: "test-gateway-token-1234567890", }); // Send an invoke result with an unknown ID (simulating late arrival after timeout) diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index b6e89486d..254365564 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -111,7 +111,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { sessionStoreSaveDelayMs.value = 0; testTailnetIPv4.value = undefined; testState.gatewayBind = undefined; - testState.gatewayAuth = undefined; + testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" }; testState.gatewayControlUi = undefined; testState.hooksConfig = undefined; testState.canvasHostPort = undefined; @@ -260,10 +260,15 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) { let port = await getFreePort(); const prev = process.env.CLAWDBOT_GATEWAY_TOKEN; - if (token === undefined) { + const fallbackToken = + token ?? + (typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? (testState.gatewayAuth as { token?: string }).token + : undefined); + if (fallbackToken === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { - process.env.CLAWDBOT_GATEWAY_TOKEN = token; + process.env.CLAWDBOT_GATEWAY_TOKEN = fallbackToken; } let server: Awaited> | null = null; @@ -299,6 +304,7 @@ export async function connectReq( opts?: { token?: string; password?: string; + skipDefaultAuth?: boolean; minProtocol?: number; maxProtocol?: number; client?: { @@ -334,6 +340,20 @@ export async function connectReq( mode: GATEWAY_CLIENT_MODES.TEST, }; const role = opts?.role ?? "operator"; + const defaultToken = + opts?.skipDefaultAuth === true + ? undefined + : typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? undefined) + : process.env.CLAWDBOT_GATEWAY_TOKEN; + const defaultPassword = + opts?.skipDefaultAuth === true + ? undefined + : typeof (testState.gatewayAuth as { password?: unknown } | undefined)?.password === "string" + ? ((testState.gatewayAuth as { password?: string }).password ?? undefined) + : process.env.CLAWDBOT_GATEWAY_PASSWORD; + const token = opts?.token ?? defaultToken; + const password = opts?.password ?? defaultPassword; const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; const device = (() => { if (opts?.device === null) return undefined; @@ -347,7 +367,7 @@ export async function connectReq( role, scopes: requestedScopes, signedAtMs, - token: opts?.token ?? null, + token: token ?? null, }); return { id: identity.deviceId, @@ -372,10 +392,10 @@ export async function connectReq( role, scopes: opts?.scopes, auth: - opts?.token || opts?.password + token || password ? { - token: opts?.token, - password: opts?.password, + token, + password, } : undefined, device, diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index f23220d9d..18c23692d 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -7,6 +7,12 @@ import { createTestRegistry } from "../test-utils/channel-plugins.js"; installGatewayTestHooks({ scope: "suite" }); +const resolveGatewayToken = (): string => { + const token = (testState.gatewayAuth as { token?: string } | undefined)?.token; + if (!token) throw new Error("test gateway token missing"); + return token; +}; + describe("POST /tools/invoke", () => { it("invokes a tool and returns {ok:true,result}", async () => { // Allow the sessions_list tool for main agent. @@ -25,10 +31,11 @@ describe("POST /tools/invoke", () => { const server = await startGatewayServer(port, { bind: "loopback", }); + const token = resolveGatewayToken(); const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }), }); @@ -105,9 +112,10 @@ describe("POST /tools/invoke", () => { const port = await getFreePort(); const server = await startGatewayServer(port, { bind: "loopback" }); try { + const token = resolveGatewayToken(); const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ tool: "sessions_list", action: "json", @@ -167,10 +175,11 @@ describe("POST /tools/invoke", () => { const port = await getFreePort(); const server = await startGatewayServer(port, { bind: "loopback" }); + const token = resolveGatewayToken(); const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }), }); @@ -198,10 +207,11 @@ describe("POST /tools/invoke", () => { const port = await getFreePort(); const server = await startGatewayServer(port, { bind: "loopback" }); + const token = resolveGatewayToken(); const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }), }); @@ -234,17 +244,18 @@ describe("POST /tools/invoke", () => { const server = await startGatewayServer(port, { bind: "loopback" }); const payload = { tool: "sessions_list", action: "json", args: {} }; + const token = resolveGatewayToken(); const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify(payload), }); expect(resDefault.status).toBe(200); const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ ...payload, sessionKey: "main" }), }); expect(resMain.status).toBe(200); diff --git a/src/security/audit.ts b/src/security/audit.ts index 3695cf049..b2f9691c7 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -211,8 +211,14 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) ? cfg.gateway.trustedProxies : []; + const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0; + const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0; + const hasSharedSecret = + (auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword); + const hasTailscaleAuth = auth.allowTailscale === true && tailscaleMode === "serve"; + const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth; - if (bind !== "loopback" && auth.mode === "none") { + if (bind !== "loopback" && !hasSharedSecret) { findings.push({ checkId: "gateway.bind_no_auth", severity: "critical", @@ -236,13 +242,13 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding }); } - if (bind === "loopback" && controlUiEnabled && auth.mode === "none") { + if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) { findings.push({ checkId: "gateway.loopback_no_auth", severity: "critical", - title: "Gateway auth disabled on loopback", + title: "Gateway auth missing on loopback", detail: - "gateway.bind is loopback and gateway.auth is disabled. " + + "gateway.bind is loopback but no gateway auth secret is configured. " + "If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.", remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.", });