fix: require gateway auth by default

This commit is contained in:
Peter Steinberger
2026-01-26 12:56:33 +00:00
parent fd9be79be1
commit c4a80f4edb
16 changed files with 103 additions and 49 deletions

View File

@@ -37,6 +37,7 @@ Status: unreleased.
### Fixes ### Fixes
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - 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. - 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 ## 2026.1.24-3

View File

@@ -2867,12 +2867,12 @@ Notes:
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - `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`. - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - 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). - 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. - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
Auth and Tailscale: 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). - `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). - 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). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).

View File

@@ -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). - `--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. - 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). - **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 <value>` / `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. - The wizard now generates a token by default, even on loopback.
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.

View File

@@ -280,7 +280,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
Bind mode controls where the Gateway listens: Bind mode controls where the Gateway listens:
- `gateway.bind: "loopback"` (default): only local clients can connect. - `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: Rules of thumb:
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access). - 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) ### 0.5) Lock down the Gateway WebSocket (local auth)
Gateway auth is **only** enforced when you set `gateway.auth`. If its unset, Gateway auth is **required by default**. If no token/password is configured,
loopback WS clients are unauthenticated — any local process can connect and call the Gateway refuses WebSocket connections (failclosed).
`config.apply`.
The onboarding wizard now generates a token by default (even for loopback) so The onboarding wizard generates a token by default (even for loopback) so
local clients must authenticate. If you skip the wizard or remove auth, youre local clients must authenticate.
back to open loopback.
Set a token so **all** WS clients must authenticate: Set a token so **all** WS clients must authenticate:

View File

@@ -91,7 +91,8 @@ Open:
## Security notes ## 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 wizard generates a gateway token by default (even on loopback).
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`. - The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
- With Serve, Tailscale identity headers can satisfy auth when - With Serve, Tailscale identity headers can satisfy auth when

View File

@@ -16,7 +16,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
## Quick start ## Quick start
1) Start the gateway. 1) Start the gateway.
2) Open the WebChat UI (macOS/iOS app) or the Control UI chat tab. 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) ## How it works (behavior)
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.

View File

@@ -249,7 +249,7 @@ describe("gateway-cli coverage", () => {
programInvalidPort.exitOverride(); programInvalidPort.exitOverride();
registerGatewayCli(programInvalidPort); registerGatewayCli(programInvalidPort);
await expect( await expect(
programInvalidPort.parseAsync(["gateway", "--port", "0"], { programInvalidPort.parseAsync(["gateway", "--port", "0", "--token", "test-token"], {
from: "user", from: "user",
}), }),
).rejects.toThrow("__exit__:1"); ).rejects.toThrow("__exit__:1");
@@ -263,7 +263,7 @@ describe("gateway-cli coverage", () => {
registerGatewayCli(programForceFail); registerGatewayCli(programForceFail);
await expect( await expect(
programForceFail.parseAsync( programForceFail.parseAsync(
["gateway", "--port", "18789", "--force", "--allow-unconfigured"], ["gateway", "--port", "18789", "--token", "test-token", "--force", "--allow-unconfigured"],
{ from: "user" }, { from: "user" },
), ),
).rejects.toThrow("__exit__:1"); ).rejects.toThrow("__exit__:1");
@@ -276,9 +276,12 @@ describe("gateway-cli coverage", () => {
const beforeSigterm = new Set(process.listeners("SIGTERM")); const beforeSigterm = new Set(process.listeners("SIGTERM"));
const beforeSigint = new Set(process.listeners("SIGINT")); const beforeSigint = new Set(process.listeners("SIGINT"));
await expect( await expect(
programStartFail.parseAsync(["gateway", "--port", "18789", "--allow-unconfigured"], { programStartFail.parseAsync(
from: "user", ["gateway", "--port", "18789", "--token", "test-token", "--allow-unconfigured"],
}), {
from: "user",
},
),
).rejects.toThrow("__exit__:1"); ).rejects.toThrow("__exit__:1");
for (const listener of process.listeners("SIGTERM")) { for (const listener of process.listeners("SIGTERM")) {
if (!beforeSigterm.has(listener)) process.removeListener("SIGTERM", listener); if (!beforeSigterm.has(listener)) process.removeListener("SIGTERM", listener);
@@ -304,7 +307,7 @@ describe("gateway-cli coverage", () => {
registerGatewayCli(program); registerGatewayCli(program);
await expect( await expect(
program.parseAsync(["gateway", "--allow-unconfigured"], { program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
from: "user", from: "user",
}), }),
).rejects.toThrow("__exit__:1"); ).rejects.toThrow("__exit__:1");
@@ -327,7 +330,7 @@ describe("gateway-cli coverage", () => {
startGatewayServer.mockRejectedValueOnce(new Error("nope")); startGatewayServer.mockRejectedValueOnce(new Error("nope"));
await expect( await expect(
program.parseAsync(["gateway", "--allow-unconfigured"], { program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
from: "user", from: "user",
}), }),
).rejects.toThrow("__exit__:1"); ).rejects.toThrow("__exit__:1");

View File

@@ -203,6 +203,10 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const resolvedAuthMode = resolvedAuth.mode; const resolvedAuthMode = resolvedAuth.mode;
const tokenValue = resolvedAuth.token; const tokenValue = resolvedAuth.token;
const passwordValue = resolvedAuth.password; 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[] = []; const authHints: string[] = [];
if (miskeys.hasGatewayToken) { if (miskeys.hasGatewayToken) {
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.'); 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.', '"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( defaultRuntime.error(
[ [
"Gateway auth is set to token, but no token is configured.", "Gateway auth is set to token, but no token is configured.",
@@ -225,7 +229,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
if (resolvedAuthMode === "password" && !passwordValue) { if (resolvedAuthMode === "password" && !hasPassword) {
defaultRuntime.error( defaultRuntime.error(
[ [
"Gateway auth is set to password, but no password is configured.", "Gateway auth is set to password, but no password is configured.",
@@ -238,11 +242,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
if (bind !== "loopback" && resolvedAuthMode === "none") { if (bind !== "loopback" && !hasSharedSecret) {
defaultRuntime.error( defaultRuntime.error(
[ [
`Refusing to bind gateway to ${bind} without auth.`, `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, ...authHints,
] ]
.filter(Boolean) .filter(Boolean)

View File

@@ -369,7 +369,8 @@ const FIELD_HELP: Record<string, string> = {
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
"agents.list[].identity.avatar": "agents.list[].identity.avatar":
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", "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.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath": "gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).", "Optional URL prefix where the Control UI is served (e.g. /clawdbot).",

View File

@@ -173,8 +173,7 @@ export function resolveGatewayAuth(params: {
const env = params.env ?? process.env; const env = params.env ?? process.env;
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
const mode: ResolvedGatewayAuth["mode"] = const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token");
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
const allowTailscale = const allowTailscale =
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password"); authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
return { return {
@@ -187,6 +186,7 @@ export function resolveGatewayAuth(params: {
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
if (auth.mode === "token" && !auth.token) { if (auth.mode === "token" && !auth.token) {
if (auth.allowTailscale) return;
throw new Error( throw new Error(
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN)", "gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN)",
); );

View File

@@ -70,6 +70,11 @@ export async function resolveGatewayRuntimeConfig(params: {
tailscaleMode, tailscaleMode,
}); });
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode; 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 hooksConfig = resolveHooksConfig(params.cfg);
const canvasHostEnabled = const canvasHostEnabled =
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false; 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)) { if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)"); 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( 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)`,
); );
} }

View File

@@ -34,7 +34,7 @@ const openWs = async (port: number) => {
}; };
describe("gateway server auth/connect", () => { describe("gateway server auth/connect", () => {
describe("default auth", () => { describe("default auth (token)", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>; let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number; let port: number;
@@ -234,6 +234,7 @@ describe("gateway server auth/connect", () => {
test("returns control ui hint when token is missing", async () => { test("returns control ui hint when token is missing", async () => {
const ws = await openWs(port); const ws = await openWs(port);
const res = await connectReq(ws, { const res = await connectReq(ws, {
skipDefaultAuth: true,
client: { client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI, id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0", 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 () => { test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
testState.gatewayAuth = { mode: "none" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const port = await getFreePort(); const port = await getFreePort();
@@ -360,7 +362,7 @@ describe("gateway server auth/connect", () => {
headers: { "x-forwarded-for": "203.0.113.10" }, headers: { "x-forwarded-for": "203.0.113.10" },
}); });
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws); const res = await connectReq(ws, { skipDefaultAuth: true });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("gateway auth required"); expect(res.error?.message ?? "").toContain("gateway auth required");
ws.close(); ws.close();

View File

@@ -28,11 +28,12 @@ let ws: WebSocket;
let port: number; let port: number;
beforeAll(async () => { beforeAll(async () => {
const started = await startServerWithClient(); const token = "test-gateway-token-1234567890";
const started = await startServerWithClient(token);
server = started.server; server = started.server;
ws = started.ws; ws = started.ws;
port = started.port; port = started.port;
await connectOk(ws); await connectOk(ws, { token });
}); });
afterAll(async () => { afterAll(async () => {
@@ -60,6 +61,7 @@ describe("late-arriving invoke results", () => {
mode: GATEWAY_CLIENT_MODES.NODE, mode: GATEWAY_CLIENT_MODES.NODE,
}, },
commands: ["canvas.snapshot"], commands: ["canvas.snapshot"],
token: "test-gateway-token-1234567890",
}); });
// Send an invoke result with an unknown ID (simulating late arrival after timeout) // Send an invoke result with an unknown ID (simulating late arrival after timeout)

View File

@@ -111,7 +111,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
sessionStoreSaveDelayMs.value = 0; sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined; testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined; testState.gatewayBind = undefined;
testState.gatewayAuth = undefined; testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" };
testState.gatewayControlUi = undefined; testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined; testState.hooksConfig = undefined;
testState.canvasHostPort = undefined; testState.canvasHostPort = undefined;
@@ -260,10 +260,15 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) { export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
let port = await getFreePort(); let port = await getFreePort();
const prev = process.env.CLAWDBOT_GATEWAY_TOKEN; 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; delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else { } else {
process.env.CLAWDBOT_GATEWAY_TOKEN = token; process.env.CLAWDBOT_GATEWAY_TOKEN = fallbackToken;
} }
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null; let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
@@ -299,6 +304,7 @@ export async function connectReq(
opts?: { opts?: {
token?: string; token?: string;
password?: string; password?: string;
skipDefaultAuth?: boolean;
minProtocol?: number; minProtocol?: number;
maxProtocol?: number; maxProtocol?: number;
client?: { client?: {
@@ -334,6 +340,20 @@ export async function connectReq(
mode: GATEWAY_CLIENT_MODES.TEST, mode: GATEWAY_CLIENT_MODES.TEST,
}; };
const role = opts?.role ?? "operator"; 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 requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => { const device = (() => {
if (opts?.device === null) return undefined; if (opts?.device === null) return undefined;
@@ -347,7 +367,7 @@ export async function connectReq(
role, role,
scopes: requestedScopes, scopes: requestedScopes,
signedAtMs, signedAtMs,
token: opts?.token ?? null, token: token ?? null,
}); });
return { return {
id: identity.deviceId, id: identity.deviceId,
@@ -372,10 +392,10 @@ export async function connectReq(
role, role,
scopes: opts?.scopes, scopes: opts?.scopes,
auth: auth:
opts?.token || opts?.password token || password
? { ? {
token: opts?.token, token,
password: opts?.password, password,
} }
: undefined, : undefined,
device, device,

View File

@@ -7,6 +7,12 @@ import { createTestRegistry } from "../test-utils/channel-plugins.js";
installGatewayTestHooks({ scope: "suite" }); 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", () => { describe("POST /tools/invoke", () => {
it("invokes a tool and returns {ok:true,result}", async () => { it("invokes a tool and returns {ok:true,result}", async () => {
// Allow the sessions_list tool for main agent. // Allow the sessions_list tool for main agent.
@@ -25,10 +31,11 @@ describe("POST /tools/invoke", () => {
const server = await startGatewayServer(port, { const server = await startGatewayServer(port, {
bind: "loopback", bind: "loopback",
}); });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST", 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" }), body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
}); });
@@ -105,9 +112,10 @@ describe("POST /tools/invoke", () => {
const port = await getFreePort(); const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" }); const server = await startGatewayServer(port, { bind: "loopback" });
try { try {
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ body: JSON.stringify({
tool: "sessions_list", tool: "sessions_list",
action: "json", action: "json",
@@ -167,10 +175,11 @@ describe("POST /tools/invoke", () => {
const port = await getFreePort(); const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" }); const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST", 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" }), body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
}); });
@@ -198,10 +207,11 @@ describe("POST /tools/invoke", () => {
const port = await getFreePort(); const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" }); const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST", 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" }), 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 server = await startGatewayServer(port, { bind: "loopback" });
const payload = { tool: "sessions_list", action: "json", args: {} }; const payload = { tool: "sessions_list", action: "json", args: {} };
const token = resolveGatewayToken();
const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
expect(resDefault.status).toBe(200); expect(resDefault.status).toBe(200);
const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ ...payload, sessionKey: "main" }), body: JSON.stringify({ ...payload, sessionKey: "main" }),
}); });
expect(resMain.status).toBe(200); expect(resMain.status).toBe(200);

View File

@@ -211,8 +211,14 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? 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({ findings.push({
checkId: "gateway.bind_no_auth", checkId: "gateway.bind_no_auth",
severity: "critical", 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({ findings.push({
checkId: "gateway.loopback_no_auth", checkId: "gateway.loopback_no_auth",
severity: "critical", severity: "critical",
title: "Gateway auth disabled on loopback", title: "Gateway auth missing on loopback",
detail: 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.", "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.", remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
}); });