diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index eb655e68aa7..9edf2d00b1e 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -634,16 +634,23 @@ describe("trusted-proxy auth", () => { }); describe("local-direct token fallback", () => { - function authorizeLocalDirect(options?: { token?: string; connectToken?: string }) { + function authorizeLocalDirect(options?: { + token?: string; + connectToken?: string; + trustedProxy?: GatewayConnectInput["auth"]["trustedProxy"]; + trustedProxies?: string[]; + }) { return authorizeGatewayConnect({ auth: { mode: "trusted-proxy", allowTailscale: false, - trustedProxy: trustedProxyConfig, + ...(Object.hasOwn(options ?? {}, "trustedProxy") + ? { trustedProxy: options?.trustedProxy } + : { trustedProxy: trustedProxyConfig }), token: options?.token, }, connectAuth: options?.connectToken ? { token: options.connectToken } : null, - trustedProxies: ["127.0.0.1"], + trustedProxies: options?.trustedProxies ?? ["127.0.0.1"], req: { socket: { remoteAddress: "127.0.0.1" }, headers: { host: "localhost" }, @@ -651,11 +658,38 @@ describe("trusted-proxy auth", () => { }); } - it("allows local-direct request without credentials", async () => { - const res = await authorizeLocalDirect({}); + it("allows local-direct request with a valid token", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "secret", + }); expect(res.ok).toBe(true); - expect(res.method).toBe("trusted-proxy"); - expect(res.user).toBe("local"); + expect(res.method).toBe("token"); + }); + + it("rejects local-direct request without credentials", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_missing"); + }); + + it("rejects local-direct request with a wrong token", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "wrong", + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_mismatch"); + }); + + it("rejects local-direct request when no local token is configured", async () => { + const res = await authorizeLocalDirect({ + connectToken: "secret", + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_missing_config"); }); it("runs full proxy auth for same-host proxy that forwards only the identity header", async () => { @@ -705,5 +739,25 @@ describe("trusted-proxy auth", () => { expect(res.ok).toBe(false); expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto"); }); + + it("still fails closed when trusted-proxy config is missing", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "secret", + trustedProxy: undefined, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_config_missing"); + }); + + it("still fails closed when trusted proxies are not configured", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "secret", + trustedProxies: [], + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_no_proxies_configured"); + }); }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index c8b591c0da8..7cadce42fdb 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -377,6 +377,27 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea return authSurface === "ws-control-ui"; } +function authorizeTokenAuth(params: { + authToken?: string; + connectToken?: string; + limiter?: AuthRateLimiter; + ip?: string; + rateLimitScope: string; +}): GatewayAuthResult { + if (!params.authToken) { + return { ok: false, reason: "token_missing_config" }; + } + if (!params.connectToken) { + return { ok: false, reason: "token_missing" }; + } + if (!safeEqualSecret(params.connectToken, params.authToken)) { + params.limiter?.recordFailure(params.ip, params.rateLimitScope); + return { ok: false, reason: "token_mismatch" }; + } + params.limiter?.reset(params.ip, params.rateLimitScope); + return { ok: true, method: "token" }; +} + export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, ): Promise { @@ -384,6 +405,12 @@ export async function authorizeGatewayConnect( const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const authSurface = params.authSurface ?? "http"; const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface); + 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 localDirect = isLocalDirectRequest( req, trustedProxies, @@ -391,18 +418,9 @@ export async function authorizeGatewayConnect( ); if (auth.mode === "trusted-proxy") { - // A local-direct request with no proxy identity header is a raw CLI/sub-agent - // connection — allow it directly as "local" without header checks. - // If the identity header IS present (same-host reverse proxy forwarding user - // identity without x-forwarded-for), fall through to authorizeTrustedProxy so - // that allowUsers and userHeader are properly evaluated. - const proxyUserHeader = auth.trustedProxy?.userHeader?.toLowerCase(); - const hasProxyIdentityHeader = - proxyUserHeader !== undefined && Boolean(req?.headers?.[proxyUserHeader]); - if (localDirect && !hasProxyIdentityHeader) { - return { ok: true, method: "trusted-proxy", user: "local" }; - } - + // Same-host reverse proxies may forward identity headers without a full + // forwarded chain; keep those on the trusted-proxy path so allowUsers and + // requiredHeaders still apply. Only raw local-direct traffic falls back. if (!auth.trustedProxy) { return { ok: false, reason: "trusted_proxy_config_missing" }; } @@ -410,6 +428,30 @@ export async function authorizeGatewayConnect( return { ok: false, reason: "trusted_proxy_no_proxies_configured" }; } + const proxyUserHeader = auth.trustedProxy?.userHeader?.toLowerCase(); + const hasProxyIdentityHeader = + proxyUserHeader !== undefined && Boolean(req?.headers?.[proxyUserHeader]); + if (localDirect && !hasProxyIdentityHeader) { + if (limiter) { + const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); + if (!rlCheck.allowed) { + return { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: rlCheck.retryAfterMs, + }; + } + } + return authorizeTokenAuth({ + authToken: auth.token, + connectToken: connectAuth?.token, + limiter, + ip, + rateLimitScope, + }); + } + const result = authorizeTrustedProxy({ req, trustedProxies, @@ -426,12 +468,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) { @@ -460,21 +496,13 @@ export async function authorizeGatewayConnect( } if (auth.mode === "token") { - if (!auth.token) { - return { ok: false, reason: "token_missing_config" }; - } - if (!connectAuth?.token) { - // Don't burn rate-limit slots for missing credentials — the client - // simply hasn't provided a token yet (e.g. bare browser open). - // Only actual *wrong* credentials should count as failures. - return { ok: false, reason: "token_missing" }; - } - 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 authorizeTokenAuth({ + authToken: auth.token, + connectToken: connectAuth?.token, + limiter, + ip, + rateLimitScope, + }); } if (auth.mode === "password") {