From 41ea7173a27587d314dbb5f94771751392d13343 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Sun, 15 Feb 2026 23:27:55 -0500 Subject: [PATCH] fix(gateway): add shared-secret fallback to trusted-proxy auth dispatcher When auth.mode is "trusted-proxy" and proxy auth fails (e.g. internal connections that bypass the reverse proxy), fall back to token/password credentials if configured. This allows CLI, node hosts, ACP, and other internal services to authenticate directly while external users authenticate via the proxy. Also enable the tailscale overlay for trusted-proxy mode by removing the mode exclusion from the allowTailscale default. --- src/gateway/auth.ts | 58 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index dbfac4c8631..0cd381a1e88 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -269,8 +269,7 @@ export function resolveGatewayAuth(params: { } const allowTailscale = - authConfig.allowTailscale ?? - (params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy"); + authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password"); return { mode, @@ -379,6 +378,13 @@ export async function authorizeGatewayConnect( 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; + if (auth.mode === "trusted-proxy") { if (!auth.trustedProxy) { return { ok: false, reason: "trusted_proxy_config_missing" }; @@ -396,6 +402,48 @@ export async function authorizeGatewayConnect( if ("user" in result) { return { ok: true, method: "trusted-proxy", user: result.user }; } + + // Trusted-proxy auth failed — try shared-secret fallback for internal + // services (CLI, node host, ACP) that bypass the reverse proxy. + // If no token/password is configured, there's no fallback available. + if (!auth.token && !auth.password) { + return { ok: false, reason: result.reason }; + } + + // Rate-limit fallback attempts + if (limiter) { + const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); + if (!rlCheck.allowed) { + return { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: rlCheck.retryAfterMs, + }; + } + } + + // Try token fallback + if (connectAuth?.token && auth.token) { + if (safeEqualSecret(connectAuth.token, auth.token)) { + limiter?.reset(ip, rateLimitScope); + return { ok: true, method: "token" }; + } + limiter?.recordFailure(ip, rateLimitScope); + return { ok: false, reason: "token_mismatch" }; + } + + // Try password fallback + if (connectAuth?.password && auth.password) { + if (safeEqualSecret(connectAuth.password, auth.password)) { + limiter?.reset(ip, rateLimitScope); + return { ok: true, method: "password" }; + } + limiter?.recordFailure(ip, rateLimitScope); + return { ok: false, reason: "password_mismatch" }; + } + + // Client didn't provide matching credentials — return original proxy failure return { ok: false, reason: result.reason }; } @@ -403,12 +451,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) {