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"); + }); +}); 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) {