diff --git a/CHANGELOG.md b/CHANGELOG.md index 03044375933..30ee7687e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai - Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out. - Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation. - Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker. +- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147. ## 2026.4.2 diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 9a56dd1a8ed..22a7b6ee161 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { assertGatewayAuthConfigured, authorizeGatewayConnect, @@ -349,6 +349,50 @@ describe("gateway auth", () => { expect(res.user).toBe("peter"); }); + it("serializes async auth attempts per rate-limit key", async () => { + const limiter = createAuthRateLimiter({ + maxAttempts: 1, + windowMs: 60_000, + lockoutMs: 60_000, + exemptLoopback: false, + }); + let releaseWhois!: () => void; + const whoisGate = new Promise((resolve) => { + releaseWhois = resolve; + }); + let whoisCalls = 0; + const tailscaleWhois = async () => { + whoisCalls += 1; + await whoisGate; + return null; + }; + + const baseParams = { + auth: { mode: "token" as const, token: "secret", allowTailscale: true }, + connectAuth: { token: "wrong" }, + tailscaleWhois, + authSurface: "ws-control-ui" as const, + req: createTailscaleForwardedReq(), + trustedProxies: ["127.0.0.1"], + rateLimiter: limiter, + }; + + const first = authorizeGatewayConnect(baseParams); + const second = authorizeGatewayConnect(baseParams); + + await vi.waitFor(() => { + expect(whoisCalls).toBe(1); + }); + releaseWhois(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult.ok).toBe(false); + expect(firstResult.reason).toBe("token_mismatch"); + expect(secondResult.ok).toBe(false); + expect(secondResult.reason).toBe("rate_limited"); + expect(whoisCalls).toBe(1); + }); + it("keeps tailscale header auth disabled on HTTP auth wrapper", async () => { await expectTailscaleHeaderAuthResult({ authorize: authorizeHttpGatewayConnect, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index e445181d9be..b65c51ea3d8 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -20,6 +20,7 @@ import { resolveClientIp, } from "./net.js"; import { checkBrowserOrigin } from "./origin-check.js"; +import { withSerializedRateLimitAttempt } from "./rate-limit-attempt-serialization.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; export type ResolvedGatewayAuthModeSource = @@ -459,6 +460,41 @@ function authorizeTokenAuth(params: { export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, +): Promise { + const { auth, req, trustedProxies } = params; + const authSurface = params.authSurface ?? "http"; + 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, + params.allowRealIpFallback === true, + ); + + // Keep the limiter strict on the async Tailscale branch by serializing + // attempts for the same {scope, ip} key across the pre-check and failure write. + if ( + limiter && + shouldAllowTailscaleHeaderAuth(authSurface) && + auth.allowTailscale && + !localDirect + ) { + return await withSerializedRateLimitAttempt({ + ip, + scope: rateLimitScope, + run: async () => await authorizeGatewayConnectCore(params), + }); + } + + return await authorizeGatewayConnectCore(params); +} + +async function authorizeGatewayConnectCore( + params: AuthorizeGatewayConnectParams, ): Promise { const { auth, connectAuth, req, trustedProxies } = params; const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; diff --git a/src/gateway/rate-limit-attempt-serialization.ts b/src/gateway/rate-limit-attempt-serialization.ts new file mode 100644 index 00000000000..7cf79f3e63f --- /dev/null +++ b/src/gateway/rate-limit-attempt-serialization.ts @@ -0,0 +1,36 @@ +import { AUTH_RATE_LIMIT_SCOPE_DEFAULT, normalizeRateLimitClientIp } from "./auth-rate-limit.js"; + +const pendingAttempts = new Map>(); + +function normalizeScope(scope: string | undefined): string { + return (scope ?? AUTH_RATE_LIMIT_SCOPE_DEFAULT).trim() || AUTH_RATE_LIMIT_SCOPE_DEFAULT; +} + +function buildSerializationKey(ip: string | undefined, scope: string | undefined): string { + return `${normalizeScope(scope)}:${normalizeRateLimitClientIp(ip)}`; +} + +export async function withSerializedRateLimitAttempt(params: { + ip: string | undefined; + scope: string | undefined; + run: () => Promise; +}): Promise { + const key = buildSerializationKey(params.ip, params.scope); + const previous = pendingAttempts.get(key) ?? Promise.resolve(); + let releaseCurrent!: () => void; + const current = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const tail = previous.catch(() => {}).then(() => current); + pendingAttempts.set(key, tail); + + await previous.catch(() => {}); + try { + return await params.run(); + } finally { + releaseCurrent(); + if (pendingAttempts.get(key) === tail) { + pendingAttempts.delete(key); + } + } +}