spb/phi-conseil
Public
1import { createHash } from "node:crypto";23/**4 * Limitation de débit en mémoire (fenêtre glissante) — adaptée à une instance5 * unique (PM2 fork / Node). Pour plusieurs instances, remplacer par un store6 * partagé (Redis) en conservant l'interface `RateLimiter`.7 */8export interface RateLimiter {9 check(key: string, now?: number): { allowed: boolean; retryAfterS: number };10}1112export function createMemoryRateLimiter(limit: number, windowS: number): RateLimiter {13 const hits = new Map<string, number[]>();14 const windowMs = windowS * 1000;15 let lastSweep = 0;16 return {17 check(key, now = Date.now()) {18 if (now - lastSweep > windowMs) {19 for (const [k, arr] of hits) {20 const kept = arr.filter((t) => now - t < windowMs);21 if (kept.length) hits.set(k, kept);22 else hits.delete(k);23 }24 lastSweep = now;25 }26 const arr = (hits.get(key) ?? []).filter((t) => now - t < windowMs);27 if (arr.length >= limit) {28 const retryAfterS = Math.ceil((arr[0] + windowMs - now) / 1000);29 hits.set(key, arr);30 return { allowed: false, retryAfterS: Math.max(1, retryAfterS) };31 }32 arr.push(now);33 hits.set(key, arr);34 return { allowed: true, retryAfterS: 0 };35 },36 };37}3839/** IP cliente derrière un reverse proxy de confiance (Caddy / nginx). */40export function clientIp(headers: Headers): string {41 const fwd = headers.get("x-forwarded-for");42 if (fwd) return fwd.split(",")[0].trim();43 return headers.get("x-real-ip") ?? "unknown";44}4546/** Empreinte non réversible pour la journalisation (pas de donnée personnelle en clair). */47export function fingerprint(value: string): string {48 return createHash("sha256").update(value).digest("hex").slice(0, 12);49}5051/** Dédoublonnage court : même expéditeur + même message dans la fenêtre. */52export function createDedupe(windowS: number) {53 const seen = new Map<string, number>();54 return {55 isDuplicate(key: string, now = Date.now()): boolean {56 for (const [k, t] of seen) if (now - t > windowS * 1000) seen.delete(k);57 if (seen.has(key)) return true;58 seen.set(key, now);59 return false;60 },61 };62}6364/** Politique de sécurité de contenu (CSP) du site. */65export function contentSecurityPolicy(): string {66 return [67 "default-src 'self'",68 "script-src 'self' 'unsafe-inline'", // inline requis par l'hydratation Next (scripts d'amorçage)69 "style-src 'self' 'unsafe-inline'",70 "img-src 'self' data: blob:",71 "font-src 'self' data:",72 "connect-src 'self'",73 "frame-ancestors 'none'",74 "base-uri 'self'",75 "form-action 'self'",76 "object-src 'none'",77 ].join("; ");78}7980export const securityHeaders = [81 { key: "Content-Security-Policy", value: contentSecurityPolicy() },82 { key: "X-Content-Type-Options", value: "nosniff" },83 { key: "X-Frame-Options", value: "DENY" },84 { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },85 {86 key: "Permissions-Policy",87 value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",88 },89 {90 key: "Strict-Transport-Security",91 value: "max-age=31536000; includeSubDomains",92 },93];94