/** * Limiteur à fenêtre glissante conçu pour l'instance Docker SONUM. * * Les clés sont conservées uniquement pendant leur fenêtre d'observation ; le * nettoyage périodique évite une croissance non bornée lors de tentatives * distribuées. Il ne stocke ni mot de passe ni contenu de message. */ export type RateLimitRule = { scope: string; maxAttempts: number; windowMs: number; }; export type RateLimitResult = | { allowed: true; remaining: number } | { allowed: false; retryAfterMs: number }; type Entry = { timestamps: number[]; windowMs: number; }; export class SlidingWindowRateLimiter { private readonly entries = new Map(); private lastCleanupAt = 0; consume(rule: RateLimitRule, key: string, now = Date.now()): RateLimitResult { this.cleanup(now); const entryKey = `${rule.scope}:${key}`; const cutoff = now - rule.windowMs; const entry = this.entries.get(entryKey) ?? { timestamps: [], windowMs: rule.windowMs }; entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff); entry.windowMs = rule.windowMs; if (entry.timestamps.length >= rule.maxAttempts) { this.entries.set(entryKey, entry); return { allowed: false, retryAfterMs: Math.max(1, entry.timestamps[0] + rule.windowMs - now), }; } entry.timestamps.push(now); this.entries.set(entryKey, entry); return { allowed: true, remaining: rule.maxAttempts - entry.timestamps.length }; } /** Réinitialise exclusivement la clé concernée après une authentification réussie. */ reset(rule: RateLimitRule, key: string) { this.entries.delete(`${rule.scope}:${key}`); } clear() { this.entries.clear(); this.lastCleanupAt = 0; } private cleanup(now: number) { // Une purge par minute est suffisante et évite un parcours de Map à chaque requête. if (now - this.lastCleanupAt < 60_000) return; this.lastCleanupAt = now; this.entries.forEach((entry, entryKey) => { const cutoff = now - entry.windowMs; entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff); if (!entry.timestamps.length) this.entries.delete(entryKey); }); } } export const rateLimiter = new SlidingWindowRateLimiter(); export const LOGIN_IDENTIFIER_RULE: RateLimitRule = { scope: "login-identifier", maxAttempts: 5, windowMs: 15 * 60_000, }; export const LOGIN_IP_RULE: RateLimitRule = { scope: "login-ip", maxAttempts: 20, windowMs: 15 * 60_000, }; export const CHANNEL_MESSAGE_RULE: RateLimitRule = { scope: "channel-message", maxAttempts: 30, windowMs: 60_000, }; export const CONTACT_REQUEST_RULE: RateLimitRule = { scope: "contact-request", maxAttempts: 5, windowMs: 60 * 60_000, }; export const INTRODUCTION_REQUEST_RULE: RateLimitRule = { scope: "introduction-request", maxAttempts: 5, windowMs: 60 * 60_000, }; /** * Traefik renseigne `x-forwarded-for`; le premier maillon représente le client. * En développement ou sans proxy, une clé neutre est utilisée plutôt qu'une IP * non fiable issue d'un type Express implicite. */ export function getClientIp(headers: Record): string { const forwarded = headers["x-forwarded-for"]; const rawValue = Array.isArray(forwarded) ? forwarded[0] : forwarded; const ip = rawValue?.split(",")[0]?.trim(); return ip || "unknown-client"; }