mirror of https://github.com/openclaw/openclaw.git
fix: serialize async auth rate-limit attempts
This commit is contained in:
parent
c63a32661a
commit
032dbf0ec6
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue