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.
This commit is contained in:
Alberto Leal 2026-02-15 23:27:55 -05:00
parent 8a05c05596
commit 41ea7173a2

View File

@ -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) {