152 lines
4.2 KiB
TypeScript
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();
|