import { createHash } from "node:crypto"; /** * Limitation de débit en mémoire (fenêtre glissante) — adaptée à une instance * unique (PM2 fork / Node). Pour plusieurs instances, remplacer par un store * partagé (Redis) en conservant l'interface `RateLimiter`. */ export interface RateLimiter { check(key: string, now?: number): { allowed: boolean; retryAfterS: number }; } export function createMemoryRateLimiter(limit: number, windowS: number): RateLimiter { const hits = new Map(); const windowMs = windowS * 1000; let lastSweep = 0; return { check(key, now = Date.now()) { if (now - lastSweep > windowMs) { for (const [k, arr] of hits) { const kept = arr.filter((t) => now - t < windowMs); if (kept.length) hits.set(k, kept); else hits.delete(k); } lastSweep = now; } const arr = (hits.get(key) ?? []).filter((t) => now - t < windowMs); if (arr.length >= limit) { const retryAfterS = Math.ceil((arr[0] + windowMs - now) / 1000); hits.set(key, arr); return { allowed: false, retryAfterS: Math.max(1, retryAfterS) }; } arr.push(now); hits.set(key, arr); return { allowed: true, retryAfterS: 0 }; }, }; } /** IP cliente derrière un reverse proxy de confiance (Caddy / nginx). */ export function clientIp(headers: Headers): string { const fwd = headers.get("x-forwarded-for"); if (fwd) return fwd.split(",")[0].trim(); return headers.get("x-real-ip") ?? "unknown"; } /** Empreinte non réversible pour la journalisation (pas de donnée personnelle en clair). */ export function fingerprint(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 12); } /** Dédoublonnage court : même expéditeur + même message dans la fenêtre. */ export function createDedupe(windowS: number) { const seen = new Map(); return { isDuplicate(key: string, now = Date.now()): boolean { for (const [k, t] of seen) if (now - t > windowS * 1000) seen.delete(k); if (seen.has(key)) return true; seen.set(key, now); return false; }, }; } /** Politique de sécurité de contenu (CSP) du site. */ export function contentSecurityPolicy(): string { return [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", // inline requis par l'hydratation Next (scripts d'amorçage) "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self' data:", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", "object-src 'none'", ].join("; "); } export const securityHeaders = [ { key: "Content-Security-Policy", value: contentSecurityPolicy() }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", }, { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains", }, ];