SPB Git

spb/phi-conseil

Public
3.1 KB · 94 lines typescript
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
Files · main
Menu

Theme