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

@@ -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");

View File

@@ -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)

View File

@@ -369,7 +369,8 @@ const FIELD_HELP: Record<string, string> = {
"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).",

View File

@@ -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)",
);

View File

@@ -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)`,
);
}

View File

@@ -34,7 +34,7 @@ const openWs = async (port: number) => {
};
describe("gateway server auth/connect", () => {
describe("default auth", () => {
describe("default auth (token)", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
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<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.error?.message ?? "").toContain("gateway auth required");
ws.close();

View File

@@ -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)

View File

@@ -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<ReturnType<typeof startGatewayServer>> | 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,

View File

@@ -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);

View File

@@ -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.",
});