fix: harden gateway auth defaults

This commit is contained in:
Peter Steinberger
2026-01-26 18:18:55 +00:00
parent ab73aceb27
commit 3314b3996e
8 changed files with 65 additions and 151 deletions

View File

@@ -43,6 +43,9 @@ Status: unreleased.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
### Breaking
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
@@ -53,6 +56,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).
- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
## 2026.1.24-3

View File

@@ -5,8 +5,8 @@ import { authorizeGatewayConnect } from "./auth.js";
describe("gateway auth", () => {
it("does not throw when req is missing socket", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: false },
connectAuth: null,
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "secret" },
// Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage.
req: {} as never,
});
@@ -63,40 +63,10 @@ describe("gateway auth", () => {
expect(res.reason).toBe("password_missing_config");
});
it("reports tailscale auth reasons when required", async () => {
const reqBase = {
socket: { remoteAddress: "100.100.100.100" },
headers: { host: "gateway.local" },
};
const missingUser = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: reqBase as never,
});
expect(missingUser.ok).toBe(false);
expect(missingUser.reason).toBe("tailscale_user_missing");
const missingProxy = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: {
...reqBase,
headers: {
host: "gateway.local",
"tailscale-user-login": "peter",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(missingProxy.ok).toBe(false);
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
});
it("treats local tailscale serve hostnames as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: { token: "secret" },
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: { host: "gateway.tailnet-1234.ts.net:443" },
@@ -104,21 +74,7 @@ describe("gateway auth", () => {
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
it("does not treat tailscale clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: {
socket: { remoteAddress: "100.64.0.42" },
headers: { host: "gateway.tailnet-1234.ts.net" },
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("tailscale_user_missing");
expect(res.method).toBe("token");
});
it("allows tailscale identity to satisfy token mode auth", async () => {
@@ -143,41 +99,4 @@ describe("gateway auth", () => {
expect(res.method).toBe("tailscale");
expect(res.user).toBe("peter");
});
it("rejects mismatched tailscale identity when required", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }),
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
"tailscale-user-login": "peter@example.com",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("tailscale_user_mismatch");
});
it("treats trusted proxy loopback clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
trustedProxies: ["10.0.0.2"],
req: {
socket: { remoteAddress: "10.0.0.2" },
headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
});

View File

@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuthMode = "token" | "password";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
@@ -14,7 +14,7 @@ export type ResolvedGatewayAuth = {
export type GatewayAuthResult = {
ok: boolean;
method?: "none" | "token" | "password" | "tailscale" | "device-token";
method?: "token" | "password" | "tailscale" | "device-token";
user?: string;
reason?: string;
};
@@ -84,7 +84,7 @@ function resolveRequestClientIp(
});
}
function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
if (!req) return false;
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
if (!isLoopbackAddress(clientIp)) return false;
@@ -219,13 +219,6 @@ export async function authorizeGatewayConnect(params: {
user: tailscaleCheck.user.login,
};
}
if (auth.mode === "none") {
return { ok: false, reason: tailscaleCheck.reason };
}
}
if (auth.mode === "none") {
return { ok: true, method: "none" };
}
if (auth.mode === "token") {

View File

@@ -181,7 +181,7 @@ describe("gateway e2e", () => {
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "none" },
auth: { mode: "token", token: wizardToken },
controlUiEnabled: false,
wizardRunner: async (_opts, _runtime, prompter) => {
await prompter.intro("Wizard E2E");
@@ -197,6 +197,7 @@ describe("gateway e2e", () => {
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: wizardToken,
clientDisplayName: "vitest-wizard",
});

View File

@@ -122,6 +122,18 @@ describe("gateway server auth/connect", () => {
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("requires nonce when host is non-local", async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { host: "example.com" },
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws);
expect(res.ok).toBe(false);
expect(res.error?.message).toBe("device nonce required");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test(
"invalid connect params surface in response and close reason",
{ timeout: 60_000 },
@@ -290,6 +302,7 @@ describe("gateway server auth/connect", () => {
test("allows control ui with device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
testState.gatewayAuth = { mode: "token", token: "secret" };
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: {
@@ -354,6 +367,7 @@ describe("gateway server auth/connect", () => {
test("allows control ui with stale device identity when device auth is disabled", async () => {
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
testState.gatewayAuth = { mode: "token", token: "secret" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
const port = await getFreePort();
@@ -399,28 +413,6 @@ 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();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { "x-forwarded-for": "203.0.113.10" },
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, { skipDefaultAuth: true });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("gateway auth required");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =

View File

@@ -23,10 +23,10 @@ import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
@@ -60,6 +60,17 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
function resolveHostName(hostHeader?: string): string {
const host = (hostHeader ?? "").trim().toLowerCase();
if (!host) return "";
if (host.startsWith("[")) {
const end = host.indexOf("]");
if (end !== -1) return host.slice(1, end);
}
const [name] = host.split(":");
return name ?? "";
}
type AuthProvidedKind = "token" | "password" | "none";
function formatGatewayAuthFailureMessage(params: {
@@ -189,8 +200,17 @@ export function attachGatewayWsMessageHandler(params: {
const hasProxyHeaders = Boolean(forwardedFor || realIp);
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp);
const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp;
const hostName = resolveHostName(requestHost);
const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
const hostIsTailscaleServe = hostName.endsWith(".ts.net");
const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies);
const reportedClientIp =
isLocalClient || hasUntrustedProxyHeaders
? undefined
: clientIp && !isLoopbackAddress(clientIp)
? clientIp
: undefined;
if (hasUntrustedProxyHeaders) {
logWsControl.warn(
@@ -199,6 +219,13 @@ export function attachGatewayWsMessageHandler(params: {
"Configure gateway.trustedProxies to restore local client detection behind your proxy.",
);
}
if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) {
logWsControl.warn(
"Loopback connection with non-local Host header. " +
"Treating it as remote. If you're behind a reverse proxy, " +
"set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.",
);
}
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
@@ -347,32 +374,6 @@ export function attachGatewayWsMessageHandler(params: {
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", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"gateway auth required behind reverse proxy",
{
details: {
hint: "set gateway.auth or configure gateway.trustedProxies",
},
},
),
});
close(1008, "gateway auth required");
return;
}
if (!device) {
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
@@ -570,7 +571,8 @@ export function attachGatewayWsMessageHandler(params: {
trustedProxies,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
let authMethod =
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
if (!authOk && connectParams.auth?.token && device) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,

View File

@@ -260,6 +260,9 @@ 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 (typeof token === "string") {
testState.gatewayAuth = { mode: "token", token };
}
const fallbackToken =
token ??
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"

View File

@@ -82,7 +82,7 @@ describe("security audit", () => {
gateway: {
bind: "loopback",
controlUi: { enabled: true },
auth: { mode: "none" as any },
auth: {},
},
};