





















@@ -8,15 +8,15 @@
88 *
99 * Design decisions:
1010 * - Pure in-memory Map – no external dependencies; suitable for a single
11- * gateway process. The Map is periodically pruned to avoid unbounded
12- * growth.
11+ * gateway process. The Map is periodically pruned and capped to avoid
12+ * unbounded growth.
1313 * - Loopback addresses (127.0.0.1 / ::1) are exempt by default so that local
1414 * CLI sessions are never locked out.
1515 * - The module is side-effect-free: callers create an instance via
1616 * {@link createAuthRateLimiter} and pass it where needed.
1717 */
181819-import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
19+import { resolveIntegerOption, resolveTimerTimeoutMs } from "../shared/number-coercion.js";
2020import { isLoopbackAddress, resolveClientIp } from "./net.js";
21212222// ---------------------------------------------------------------------------
@@ -34,6 +34,8 @@ export interface RateLimitConfig {
3434exemptLoopback?: boolean;
3535/** Background prune interval in milliseconds; set <= 0 to disable auto-prune. @default 60_000 */
3636pruneIntervalMs?: number;
37+/** Maximum tracked client identities before old unlocked entries are evicted. @default 10_000 */
38+maxEntries?: number;
3739}
38403941export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default";
@@ -96,6 +98,7 @@ const DEFAULT_MAX_ATTEMPTS = 10;
9698const DEFAULT_WINDOW_MS = 60_000; // 1 minute
9799const DEFAULT_LOCKOUT_MS = 300_000; // 5 minutes
98100const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute
101+const DEFAULT_MAX_ENTRIES = 10_000;
99102100103// ---------------------------------------------------------------------------
101104// Implementation
@@ -137,8 +140,10 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
137140const lockoutMs = resolveTimerTimeoutMs(config?.lockoutMs, DEFAULT_LOCKOUT_MS, 0);
138141const exemptLoopback = config?.exemptLoopback ?? true;
139142const pruneIntervalMs = resolvePruneIntervalMs(config?.pruneIntervalMs);
143+const maxEntries = resolveIntegerOption(config?.maxEntries, DEFAULT_MAX_ENTRIES, { min: 1 });
140144141145const entries = new Map<string, RateLimitEntry>();
146+let overflowLockedUntil: number | undefined;
142147143148// Periodic cleanup to avoid unbounded map growth.
144149const pruneTimer = pruneIntervalMs > 0 ? setInterval(() => prune(), pruneIntervalMs) : null;
@@ -187,6 +192,10 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
187192const entry = entries.get(key);
188193189194if (!entry) {
195+const overflowLock = checkOverflowLock(now);
196+if (overflowLock) {
197+return overflowLock;
198+}
190199return { allowed: true, remaining: maxAttempts, retryAfterMs: 0 };
191200}
192201@@ -220,6 +229,10 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
220229let entry = entries.get(key);
221230222231if (!entry) {
232+if (!enforceMaxEntries(now)) {
233+overflowLockedUntil = Math.max(overflowLockedUntil ?? 0, now + lockoutMs);
234+return;
235+}
223236entry = { attempts: [] };
224237entries.set(key, entry);
225238}
@@ -242,8 +255,7 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
242255entries.delete(key);
243256}
244257245-function prune(): void {
246-const now = Date.now();
258+function pruneExpiredEntries(now: number): void {
247259for (const [key, entry] of entries) {
248260// If locked out, keep the entry until the lockout expires.
249261if (entry.lockedUntil && now < entry.lockedUntil) {
@@ -256,6 +268,52 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
256268}
257269}
258270271+function checkOverflowLock(now: number): RateLimitCheckResult | undefined {
272+if (!overflowLockedUntil) {
273+return undefined;
274+}
275+if (now >= overflowLockedUntil) {
276+overflowLockedUntil = undefined;
277+return undefined;
278+}
279+if (entries.size >= maxEntries) {
280+pruneExpiredEntries(now);
281+}
282+if (entries.size < maxEntries) {
283+overflowLockedUntil = undefined;
284+return undefined;
285+}
286+return {
287+allowed: false,
288+remaining: 0,
289+retryAfterMs: overflowLockedUntil - now,
290+};
291+}
292+293+function enforceMaxEntries(now: number): boolean {
294+if (entries.size < maxEntries) {
295+return true;
296+}
297+298+pruneExpiredEntries(now);
299+if (entries.size < maxEntries) {
300+return true;
301+}
302+303+// Preserve active lockouts so a flood cannot evict the attacker's own block.
304+for (const [entryKey, entry] of entries) {
305+if (!entry.lockedUntil || now >= entry.lockedUntil) {
306+entries.delete(entryKey);
307+return true;
308+}
309+}
310+return false;
311+}
312+313+function prune(): void {
314+pruneExpiredEntries(Date.now());
315+}
316+259317function size(): number {
260318return entries.size;
261319}
@@ -265,6 +323,7 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
265323clearInterval(pruneTimer);
266324}
267325entries.clear();
326+overflowLockedUntil = undefined;
268327}
269328270329return { check, recordFailure, reset, size, prune, dispose };
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。