From 1cab57f25770317defd995db920bb3938ba70f3b Mon Sep 17 00:00:00 2001 From: rick Date: Tue, 3 Mar 2026 21:03:21 -0600 Subject: [PATCH] fix(gateway): allow local shared-secret auth in trusted-proxy mode --- src/gateway/auth.test.ts | 22 ++++++++++++++ src/gateway/auth.ts | 63 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 07d90d2d134..974e2a2074d 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -395,6 +395,28 @@ describe("trusted-proxy auth", () => { expect(res.user).toBe("nick@example.com"); }); + it("allows local-direct shared-token auth in trusted-proxy mode", async () => { + const localToken = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + token: "secret", + trustedProxy: trustedProxyConfig, + }, + connectAuth: { token: "secret" }, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "127.0.0.1:19001", + }, + } as never, + }); + + expect(localToken.ok).toBe(true); + expect(localToken.method).toBe("token"); + }); + it("rejects request from untrusted source", async () => { const res = await authorizeTrustedProxy({ remoteAddress: "192.168.1.100", diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 6315a899e76..b5f9e885ad9 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -75,6 +75,14 @@ export type AuthorizeGatewayConnectParams = { allowRealIpFallback?: boolean; }; +type SharedSecretAuthParams = { + auth: ResolvedGatewayAuth; + connectAuth?: ConnectAuth | null; + limiter?: AuthRateLimiter; + ip?: string; + rateLimitScope: string; +}; + type TailscaleUser = { login: string; name: string; @@ -361,6 +369,30 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea return authSurface === "ws-control-ui"; } +function authorizeSharedSecretFallback(params: SharedSecretAuthParams): GatewayAuthResult | null { + const { auth, connectAuth, limiter, ip, rateLimitScope } = params; + + if (auth.password && connectAuth?.password) { + if (!safeEqualSecret(connectAuth.password, auth.password)) { + limiter?.recordFailure(ip, rateLimitScope); + return { ok: false, reason: "password_mismatch" }; + } + limiter?.reset(ip, rateLimitScope); + return { ok: true, method: "password" }; + } + + if (auth.token && connectAuth?.token) { + if (!safeEqualSecret(connectAuth.token, auth.token)) { + limiter?.recordFailure(ip, rateLimitScope); + return { ok: false, reason: "token_mismatch" }; + } + limiter?.reset(ip, rateLimitScope); + return { ok: true, method: "token" }; + } + + return null; +} + export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, ): Promise { @@ -373,8 +405,33 @@ export async function authorizeGatewayConnect( trustedProxies, params.allowRealIpFallback === true, ); + const limiter = params.rateLimiter; + const ip = + params.clientIp ?? + resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ?? + req?.socket?.remoteAddress; + const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET; + const localLoopbackWithoutProxyHeaders = + Boolean(req) && + isLoopbackAddress(req?.socket?.remoteAddress) && + !req?.headers?.["x-forwarded-for"] && + !req?.headers?.["x-real-ip"] && + !req?.headers?.["x-forwarded-host"]; if (auth.mode === "trusted-proxy") { + if (localDirect || localLoopbackWithoutProxyHeaders) { + const sharedSecretFallback = authorizeSharedSecretFallback({ + auth, + connectAuth, + limiter, + ip, + rateLimitScope, + }); + if (sharedSecretFallback) { + return sharedSecretFallback; + } + } + if (!auth.trustedProxy) { return { ok: false, reason: "trusted_proxy_config_missing" }; } @@ -398,12 +455,6 @@ export async function authorizeGatewayConnect( return { ok: true, method: "none" }; } - const limiter = params.rateLimiter; - const ip = - params.clientIp ?? - resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ?? - req?.socket?.remoteAddress; - const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET; if (limiter) { const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); if (!rlCheck.allowed) {