From 41ea7173a27587d314dbb5f94771751392d13343 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Sun, 15 Feb 2026 23:27:55 -0500 Subject: [PATCH 1/3] 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) { From dc2c561f6038adbe15fd18d0edf7e0312c0d10cf Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Sun, 15 Feb 2026 23:28:02 -0500 Subject: [PATCH 2/3] test(gateway): add tests for trusted-proxy shared-secret fallback Add 9 unit tests covering fallback behavior: proxy success unchanged, token/password fallback on valid credentials, rejection on mismatch, no-fallback when server credentials unconfigured, rate limiting on fallback attempts, and proxy-takes-priority when both are available. Add 3 e2e tests covering internal connection scenarios: token auth with device identity, token auth without device identity (canSkipDevice), and proxy connection priority over token fallback. --- src/gateway/auth.test.ts | 188 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 1488b438237..84c20431448 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -650,3 +650,191 @@ describe("trusted-proxy auth", () => { expect(res.user).toBe("nick@example.com"); }); }); + +describe("trusted-proxy shared-secret fallback", () => { + const trustedProxyConfig = { + userHeader: "x-forwarded-user", + }; + + const untrustedReq = { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + }, + } as never; + + // trustedProxies: ["10.0.0.1"] means 127.0.0.1 is NOT trusted + const untrustedProxies = ["10.0.0.1"]; + + it("trusted-proxy via proxy succeeds (unchanged)", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + token: "server-token", + }, + connectAuth: null, + trustedProxies: ["10.0.0.1"], + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("trusted-proxy"); + expect(res.user).toBe("nick@example.com"); + }); + + it("rejects untrusted IP when no token/password configured (unchanged)", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: untrustedProxies, + req: untrustedReq, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_untrusted_source"); + }); + + it("falls back to token when untrusted IP provides valid token", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + token: "server-token", + }, + connectAuth: { token: "server-token" }, + trustedProxies: untrustedProxies, + req: untrustedReq, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("token"); + }); + + it("rejects untrusted IP with invalid token", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + token: "server-token", + }, + connectAuth: { token: "wrong-token" }, + trustedProxies: untrustedProxies, + req: untrustedReq, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_mismatch"); + }); + + it("falls back to password when untrusted IP provides valid password", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + password: "server-pass", + }, + connectAuth: { password: "server-pass" }, + trustedProxies: untrustedProxies, + req: untrustedReq, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("password"); + }); + + it("rejects untrusted IP with invalid password", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + password: "server-pass", + }, + connectAuth: { password: "wrong-pass" }, + trustedProxies: untrustedProxies, + req: untrustedReq, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("password_mismatch"); + }); + + it("rejects when client provides token but no server token configured", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + // no token or password configured on server + }, + connectAuth: { token: "client-token" }, + trustedProxies: untrustedProxies, + req: untrustedReq, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_untrusted_source"); + }); + + it("rejects when rate limited during fallback", async () => { + const limiter = createLimiterSpy(); + limiter.check.mockReturnValue({ allowed: false, remaining: 0, retryAfterMs: 5000 }); + + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + token: "server-token", + }, + connectAuth: { token: "server-token" }, + trustedProxies: untrustedProxies, + req: untrustedReq, + rateLimiter: limiter, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("rate_limited"); + expect(res.rateLimited).toBe(true); + }); + + it("uses proxy method when request comes from trusted proxy even if token is provided", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + token: "server-token", + }, + connectAuth: { token: "server-token" }, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-user": "nick@example.com", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("trusted-proxy"); + expect(res.user).toBe("nick@example.com"); + }); +}); From 2a7348428c62bf9b35f68bad2ee7e7cc50aaf20a Mon Sep 17 00:00:00 2001 From: Alberto Leal <139499+dashed@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:56:01 +0000 Subject: [PATCH 3/3] test(gateway): fix trusted-proxy challenge message typing