fix: serialize async auth rate-limit attempts

This commit is contained in:
Peter Steinberger 2026-04-04 21:47:48 +09:00
parent c63a32661a
commit 032dbf0ec6
No known key found for this signature in database
4 changed files with 118 additions and 1 deletions

View File

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

View File

@ -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<void>((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,

View File

@ -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<GatewayAuthResult> {
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<GatewayAuthResult> {
const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;

View File

@ -0,0 +1,36 @@
import { AUTH_RATE_LIMIT_SCOPE_DEFAULT, normalizeRateLimitClientIp } from "./auth-rate-limit.js";
const pendingAttempts = new Map<string, Promise<void>>();
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<T>(params: {
ip: string | undefined;
scope: string | undefined;
run: () => Promise<T>;
}): Promise<T> {
const key = buildSerializationKey(params.ip, params.scope);
const previous = pendingAttempts.get(key) ?? Promise.resolve();
let releaseCurrent!: () => void;
const current = new Promise<void>((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);
}
}
}