fix: harden gateway auth defaults
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("security audit", () => {
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
controlUi: { enabled: true },
|
||||
auth: { mode: "none" as any },
|
||||
auth: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user