From d8a63bc4d81a64ce5252807b95c41009f842b23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Mon, 13 Apr 2026 21:30:38 +0400 Subject: [PATCH] feat: rate limiting sur les routes d'authentification critiques MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout de lib/rate-limit.js : fabrique de limiter en mémoire (closure + Map avec nettoyage lazy), sans dépendance externe, réutilisable - Ajout de middleware.js : intercepte /api/auth/register (5 req/15min) et /api/auth/callback/credentials (10 req/5min), répond 429 + Retry-After - Ajout de tasks/todo.md et tasks/lessons.md (suivi CLAUDE.md) Co-Authored-By: Claude Sonnet 4.6 --- lib/rate-limit.js | 61 +++++++++++++++++++++++++++++++++++++++++++++++ middleware.js | 59 +++++++++++++++++++++++++++++++++++++++++++++ tasks/lessons.md | 18 ++++++++++++++ tasks/todo.md | 27 +++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 lib/rate-limit.js create mode 100644 middleware.js create mode 100644 tasks/lessons.md create mode 100644 tasks/todo.md diff --git a/lib/rate-limit.js b/lib/rate-limit.js new file mode 100644 index 0000000..f7ebd78 --- /dev/null +++ b/lib/rate-limit.js @@ -0,0 +1,61 @@ +/** + * Fabrique de rate-limiter en mémoire. + * + * Adapté à un déploiement single-process (PM2 sans cluster). + * Pour un déploiement multi-instances, remplacer le Map par un store + * Redis partagé (ex. @upstash/ratelimit). + * + * @param {object} options + * @param {number} options.windowMs - Durée de la fenêtre en millisecondes + * @param {number} options.max - Nombre maximum de requêtes par fenêtre + */ +export function createRateLimiter({windowMs, max}) { + const store = new Map() + let lastCleanup = Date.now() + + /** + * Supprime les entrées expirées du store. + * Appelé automatiquement une fois par fenêtre temporelle. + */ + function cleanup(now) { + for (const [storeKey, entry] of store) { + if (now - entry.start >= windowMs) { + store.delete(storeKey) + } + } + + lastCleanup = now + } + + /** + * Vérifie si la clé (IP:route) dépasse la limite autorisée. + * + * @param {string} key - Identifiant unique (ex. "1.2.3.4:/api/auth/register") + * @returns {{ success: boolean, retryAfter?: number }} + */ + return key => { + const now = Date.now() + + if (now - lastCleanup >= windowMs) { + cleanup(now) + } + + const entry = store.get(key) + + // Première requête ou fenêtre expirée : on repart à zéro + if (!entry || now - entry.start >= windowMs) { + store.set(key, {count: 1, start: now}) + return {success: true} + } + + // Limite atteinte + if (entry.count >= max) { + const retryAfter = Math.ceil((entry.start + windowMs - now) / 1000) + return {success: false, retryAfter} + } + + // Incrément normal + store.set(key, {...entry, count: entry.count + 1}) + return {success: true} + } +} diff --git a/middleware.js b/middleware.js new file mode 100644 index 0000000..2f92abb --- /dev/null +++ b/middleware.js @@ -0,0 +1,59 @@ +import {NextResponse} from 'next/server' +import {createRateLimiter} from '@/lib/rate-limit.js' + +// 5 inscriptions max par IP toutes les 15 minutes +const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5}) + +// 10 tentatives de connexion max par IP toutes les 5 minutes +const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10}) + +const limiters = { + '/api/auth/register': checkRegister, + '/api/auth/callback/credentials': checkSignin, +} + +/** + * Extrait l'IP cliente depuis les headers HTTP. + * Priorité à X-Real-IP (Nginx), puis X-Forwarded-For. + */ +function getClientIp(request) { + const realIp = request.headers.get('x-real-ip') + if (realIp) { + return realIp.trim() + } + + const forwarded = request.headers.get('x-forwarded-for') + if (forwarded) { + return forwarded.split(',')[0].trim() + } + + return 'unknown' +} + +export function middleware(request) { + const {pathname} = request.nextUrl + const check = limiters[pathname] + + if (!check) { + return NextResponse.next() + } + + const ip = getClientIp(request) + const result = check(`${ip}:${pathname}`) + + if (result.success) { + return NextResponse.next() + } + + return NextResponse.json( + {message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'}, + { + status: 429, + headers: {'Retry-After': String(result.retryAfter)}, + } + ) +} + +export const config = { + matcher: ['/api/auth/register', '/api/auth/callback/credentials'], +} diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..49d38b9 --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,18 @@ +# Leçons — Konstitisyon Frontend + +## 2026-04-13 — Rate limiting / Premier chantier + +### Erreur commise +Implémentation directe sans passer par le mode planification ni créer `tasks/todo.md` avant de coder. +La tâche a été présentée comme terminée après validation XO, sans preuve de fonctionnement réel. + +### Règle à retenir +1. **Toujours** créer/mettre à jour `tasks/todo.md` avant de toucher au code pour toute tâche 3+ étapes. +2. **Toujours** prouver le fonctionnement après implémentation : test unitaire, `curl`, ou vérification Node directe — pas seulement le linter. +3. Le linter qui passe ≠ la feature qui fonctionne. + +### Ce qui a bien fonctionné +- Lecture complète des fichiers impactés avant d'écrire (middleware, register, options, package.json) +- Zéro dépendance externe ajoutée — solution en-mémoire adaptée à PM2 single-process +- Correction autonome des erreurs XO (curly, arrow-parens, func-names) sans intervention +- Abstraction propre : `createRateLimiter` est réutilisable pour d'autres routes futures diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..d5fdb0a --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,27 @@ +# Tâches — Konstitisyon Frontend + +## Améliorations critiques (P1) + +- [x] **Rate limiting** — `lib/rate-limit.js` + `middleware.js` + - Routes protégées : `/api/auth/register` (5/15min) et `/api/auth/callback/credentials` (10/5min) + - Logique vérifiée : comptage, blocage 429 + Retry-After, expiration fenêtre ✓ +- [ ] **CORS whitelist** — restreindre `CORS_ORIGIN=true` dans l'env Directus +- [ ] **Sanitisation Markdown** — ajouter `isomorphic-dompurify` dans `markdown-renderer` + +## Améliorations hautes (P2) + +- [ ] **Headers CSP** — ajouter dans `next.config.js` +- [ ] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js` +- [ ] **Tests extensions Directus** — mocks VersionsService +- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options +- [ ] **Pipeline CI** — GitHub Actions (lint + test + build) +- [ ] **Sentry** — tracking erreurs frontend + API routes + +## Améliorations moyennes (P3) + +- [ ] ISR page d'accueil (`revalidate`) +- [ ] Dockerisation frontend (`output: standalone`) +- [ ] Audit accessibilité WCAG 2.1 +- [ ] Responsive mobile dashboard +- [ ] Lazy loading jsPDF + md-editor +- [ ] Migration NextAuth v5 stable