Files
openmaic/OpenMAIC/lib/server/access-code-rate-limit.ts
2026-08-16 14:58:47 +08:00

152 lines
4.2 KiB
TypeScript

export const DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_ATTEMPTS = 10;
export const DEFAULT_ACCESS_CODE_RATE_LIMIT_WINDOW_SECONDS = 15 * 60;
export const DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_CLIENTS = 10_000;
export interface AccessCodeRateLimitConfig {
maxAttempts: number;
windowMs: number;
maxClients: number;
}
export interface AccessCodeRateLimitDecision {
allowed: boolean;
remaining: number;
retryAfterSeconds: number;
}
interface AttemptBucket {
count: number;
resetAtMs: number;
}
function positiveInteger(value: string | undefined, fallback: number, maximum: number): number {
if (!value) return fallback;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= maximum ? parsed : fallback;
}
export function resolveAccessCodeRateLimitConfig(
env: Record<string, string | undefined> = process.env,
): AccessCodeRateLimitConfig {
return {
maxAttempts: positiveInteger(
env.ACCESS_CODE_RATE_LIMIT_MAX_ATTEMPTS,
DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_ATTEMPTS,
10_000,
),
windowMs:
positiveInteger(
env.ACCESS_CODE_RATE_LIMIT_WINDOW_SECONDS,
DEFAULT_ACCESS_CODE_RATE_LIMIT_WINDOW_SECONDS,
24 * 60 * 60,
) * 1000,
maxClients: positiveInteger(
env.ACCESS_CODE_RATE_LIMIT_MAX_CLIENTS,
DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_CLIENTS,
100_000,
),
};
}
export function shouldTrustAccessCodeProxyHeaders(
value = process.env.ACCESS_CODE_TRUST_PROXY_HEADERS,
): boolean {
return value === 'true' || value === '1';
}
function firstForwardedAddress(value: string | null): string | null {
const address = value?.split(',')[0]?.trim();
if (!address) return null;
return address.slice(0, 128);
}
/**
* NextRequest no longer exposes a transport-level remote address. Proxy IP
* headers are therefore used only through an explicit trust opt-in. Without
* it, all attempts share one conservative process-local bucket.
*/
export function resolveAccessCodeRateLimitKey(
request: Request,
trustProxyHeaders = shouldTrustAccessCodeProxyHeaders(),
): string {
if (trustProxyHeaders) {
const address =
firstForwardedAddress(request.headers.get('x-forwarded-for')) ??
firstForwardedAddress(request.headers.get('x-real-ip'));
if (address) return `ip:${address}`;
}
return 'shared:unknown-client';
}
/** Bounded, single-process fixed-window limiter for ACCESS_CODE login attempts. */
export class AccessCodeRateLimiter {
private readonly buckets = new Map<string, AttemptBucket>();
consume(
key: string,
config: AccessCodeRateLimitConfig,
nowMs = Date.now(),
): AccessCodeRateLimitDecision {
this.pruneExpired(nowMs);
const current = this.buckets.get(key);
if (current && current.resetAtMs > nowMs) {
if (current.count >= config.maxAttempts) {
return {
allowed: false,
remaining: 0,
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAtMs - nowMs) / 1000)),
};
}
current.count += 1;
this.touch(key, current);
return {
allowed: true,
remaining: Math.max(0, config.maxAttempts - current.count),
retryAfterSeconds: 0,
};
}
this.ensureCapacity(config.maxClients);
this.buckets.set(key, { count: 1, resetAtMs: nowMs + config.windowMs });
return {
allowed: true,
remaining: Math.max(0, config.maxAttempts - 1),
retryAfterSeconds: 0,
};
}
reset(key: string): void {
this.buckets.delete(key);
}
clear(): void {
this.buckets.clear();
}
get size(): number {
return this.buckets.size;
}
private touch(key: string, bucket: AttemptBucket): void {
this.buckets.delete(key);
this.buckets.set(key, bucket);
}
private pruneExpired(nowMs: number): void {
for (const [key, bucket] of this.buckets) {
if (bucket.resetAtMs <= nowMs) this.buckets.delete(key);
}
}
private ensureCapacity(maxClients: number): void {
while (this.buckets.size >= maxClients) {
const oldestKey = this.buckets.keys().next().value as string | undefined;
if (!oldestKey) return;
this.buckets.delete(oldestKey);
}
}
}
export const accessCodeRateLimiter = new AccessCodeRateLimiter();