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.
|
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
- 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
|
### Fixes
|
||||||
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
- 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.
|
- 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.
|
- 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.
|
- 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: 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.
|
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||||
|
|
||||||
## 2026.1.24-3
|
## 2026.1.24-3
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { authorizeGatewayConnect } from "./auth.js";
|
|||||||
describe("gateway auth", () => {
|
describe("gateway auth", () => {
|
||||||
it("does not throw when req is missing socket", async () => {
|
it("does not throw when req is missing socket", async () => {
|
||||||
const res = await authorizeGatewayConnect({
|
const res = await authorizeGatewayConnect({
|
||||||
auth: { mode: "none", allowTailscale: false },
|
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||||
connectAuth: null,
|
connectAuth: { token: "secret" },
|
||||||
// Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage.
|
// Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage.
|
||||||
req: {} as never,
|
req: {} as never,
|
||||||
});
|
});
|
||||||
@@ -63,40 +63,10 @@ describe("gateway auth", () => {
|
|||||||
expect(res.reason).toBe("password_missing_config");
|
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 () => {
|
it("treats local tailscale serve hostnames as direct", async () => {
|
||||||
const res = await authorizeGatewayConnect({
|
const res = await authorizeGatewayConnect({
|
||||||
auth: { mode: "none", allowTailscale: true },
|
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||||
connectAuth: null,
|
connectAuth: { token: "secret" },
|
||||||
req: {
|
req: {
|
||||||
socket: { remoteAddress: "127.0.0.1" },
|
socket: { remoteAddress: "127.0.0.1" },
|
||||||
headers: { host: "gateway.tailnet-1234.ts.net:443" },
|
headers: { host: "gateway.tailnet-1234.ts.net:443" },
|
||||||
@@ -104,21 +74,7 @@ describe("gateway auth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.method).toBe("none");
|
expect(res.method).toBe("token");
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows tailscale identity to satisfy token mode auth", async () => {
|
it("allows tailscale identity to satisfy token mode auth", async () => {
|
||||||
@@ -143,41 +99,4 @@ describe("gateway auth", () => {
|
|||||||
expect(res.method).toBe("tailscale");
|
expect(res.method).toBe("tailscale");
|
||||||
expect(res.user).toBe("peter");
|
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 type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||||
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||||
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
||||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
export type ResolvedGatewayAuthMode = "token" | "password";
|
||||||
|
|
||||||
export type ResolvedGatewayAuth = {
|
export type ResolvedGatewayAuth = {
|
||||||
mode: ResolvedGatewayAuthMode;
|
mode: ResolvedGatewayAuthMode;
|
||||||
@@ -14,7 +14,7 @@ export type ResolvedGatewayAuth = {
|
|||||||
|
|
||||||
export type GatewayAuthResult = {
|
export type GatewayAuthResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
method?: "none" | "token" | "password" | "tailscale" | "device-token";
|
method?: "token" | "password" | "tailscale" | "device-token";
|
||||||
user?: string;
|
user?: string;
|
||||||
reason?: 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;
|
if (!req) return false;
|
||||||
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
|
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
|
||||||
if (!isLoopbackAddress(clientIp)) return false;
|
if (!isLoopbackAddress(clientIp)) return false;
|
||||||
@@ -219,13 +219,6 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
user: tailscaleCheck.user.login,
|
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") {
|
if (auth.mode === "token") {
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe("gateway e2e", () => {
|
|||||||
const port = await getFreeGatewayPort();
|
const port = await getFreeGatewayPort();
|
||||||
const server = await startGatewayServer(port, {
|
const server = await startGatewayServer(port, {
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
auth: { mode: "none" },
|
auth: { mode: "token", token: wizardToken },
|
||||||
controlUiEnabled: false,
|
controlUiEnabled: false,
|
||||||
wizardRunner: async (_opts, _runtime, prompter) => {
|
wizardRunner: async (_opts, _runtime, prompter) => {
|
||||||
await prompter.intro("Wizard E2E");
|
await prompter.intro("Wizard E2E");
|
||||||
@@ -197,6 +197,7 @@ describe("gateway e2e", () => {
|
|||||||
|
|
||||||
const client = await connectGatewayClient({
|
const client = await connectGatewayClient({
|
||||||
url: `ws://127.0.0.1:${port}`,
|
url: `ws://127.0.0.1:${port}`,
|
||||||
|
token: wizardToken,
|
||||||
clientDisplayName: "vitest-wizard",
|
clientDisplayName: "vitest-wizard",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,18 @@ describe("gateway server auth/connect", () => {
|
|||||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
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(
|
test(
|
||||||
"invalid connect params surface in response and close reason",
|
"invalid connect params surface in response and close reason",
|
||||||
{ timeout: 60_000 },
|
{ 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 () => {
|
test("allows control ui with device identity when insecure auth is enabled", async () => {
|
||||||
testState.gatewayControlUi = { allowInsecureAuth: true };
|
testState.gatewayControlUi = { allowInsecureAuth: true };
|
||||||
|
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||||
const { writeConfigFile } = await import("../config/config.js");
|
const { writeConfigFile } = await import("../config/config.js");
|
||||||
await writeConfigFile({
|
await writeConfigFile({
|
||||||
gateway: {
|
gateway: {
|
||||||
@@ -354,6 +367,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
|
|
||||||
test("allows control ui with stale device identity when device auth is disabled", async () => {
|
test("allows control ui with stale device identity when device auth is disabled", async () => {
|
||||||
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
||||||
|
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||||
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
|
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
|
||||||
const port = await getFreePort();
|
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 () => {
|
test("accepts device token auth for paired device", async () => {
|
||||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ import { rawDataToString } from "../../../infra/ws.js";
|
|||||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||||
import type { ResolvedGatewayAuth } from "../../auth.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 { loadConfig } from "../../../config/config.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.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 { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||||
import {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
@@ -60,6 +60,17 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
|||||||
|
|
||||||
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
|
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";
|
type AuthProvidedKind = "token" | "password" | "none";
|
||||||
|
|
||||||
function formatGatewayAuthFailureMessage(params: {
|
function formatGatewayAuthFailureMessage(params: {
|
||||||
@@ -189,8 +200,17 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
const hasProxyHeaders = Boolean(forwardedFor || realIp);
|
const hasProxyHeaders = Boolean(forwardedFor || realIp);
|
||||||
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
|
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
|
||||||
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
|
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
|
||||||
const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp);
|
const hostName = resolveHostName(requestHost);
|
||||||
const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp;
|
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) {
|
if (hasUntrustedProxyHeaders) {
|
||||||
logWsControl.warn(
|
logWsControl.warn(
|
||||||
@@ -199,6 +219,13 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
"Configure gateway.trustedProxies to restore local client detection behind your proxy.",
|
"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);
|
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||||
|
|
||||||
@@ -347,32 +374,6 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
|
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
|
||||||
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
|
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
|
||||||
const device = disableControlUiDeviceAuth ? null : deviceRaw;
|
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) {
|
if (!device) {
|
||||||
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
|
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
|
||||||
|
|
||||||
@@ -570,7 +571,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
trustedProxies,
|
trustedProxies,
|
||||||
});
|
});
|
||||||
let authOk = authResult.ok;
|
let authOk = authResult.ok;
|
||||||
let authMethod = authResult.method ?? "none";
|
let authMethod =
|
||||||
|
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
|
||||||
if (!authOk && connectParams.auth?.token && device) {
|
if (!authOk && connectParams.auth?.token && device) {
|
||||||
const tokenCheck = await verifyDeviceToken({
|
const tokenCheck = await verifyDeviceToken({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ 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 (typeof token === "string") {
|
||||||
|
testState.gatewayAuth = { mode: "token", token };
|
||||||
|
}
|
||||||
const fallbackToken =
|
const fallbackToken =
|
||||||
token ??
|
token ??
|
||||||
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
|
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe("security audit", () => {
|
|||||||
gateway: {
|
gateway: {
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
controlUi: { enabled: true },
|
controlUi: { enabled: true },
|
||||||
auth: { mode: "none" as any },
|
auth: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user