Selaa lähdekoodia

feat(security): Implement CSRF protection and security headers

- Added a new class `securityHeaders` to manage HTTP security headers.
- Integrated CSP (Content Security Policy) with nonce support for inline scripts.
- Enhanced login functionality with rate limiting and honeypot protection.
- Implemented CSRF validation for JSON and form submissions across various actions.
- Updated login page and AJAX requests to include CSRF tokens.
- Improved error handling for CSRF validation failures.
- Refactored existing code to utilize new security measures.
stany.ferer 2 viikkoa sitten
vanhempi
commit
2279a62be1

+ 7 - 5
core/class/csrf.class.php

@@ -44,9 +44,10 @@ class csrf
      * @param string $token Le token à valider
      * @param string $formName Nom du formulaire correspondant
      * @param int $maxAge Durée de validité du token en secondes (par défaut 3600 = 1h)
+     * @param bool $consumeToken Si true, le token est supprimé après validation (défaut: true)
      * @return bool True si le token est valide, False sinon
      */
-    public static function validateToken(string $token, string $formName = 'default', int $maxAge = 3600): bool
+    public static function validateToken(string $token, string $formName = 'default', int $maxAge = 3600, bool $consumeToken = true): bool
     {
         // Vérifier qu'une session est active
         if (session_status() !== PHP_SESSION_ACTIVE) {
@@ -69,8 +70,8 @@ class csrf
         // Comparer les tokens de manière sécurisée (timing-safe)
         $isValid = hash_equals($storedData['token'], $token);
 
-        // Token à usage unique : supprimer après validation
-        if ($isValid) {
+        // Token à usage unique : supprimer après validation (si demandé)
+        if ($isValid && $consumeToken) {
             self::removeToken($formName);
         }
 
@@ -158,15 +159,16 @@ class csrf
      * @param string $formName Nom du formulaire
      * @param string $fieldName Nom du champ POST (par défaut 'csrf_token')
      * @param int $maxAge Durée de validité en secondes
+     * @param bool $consumeToken Si true, le token est supprimé après validation (défaut: true)
      * @return bool True si le token POST est valide
      */
-    public static function validatePost(string $formName = 'default', string $fieldName = 'csrf_token', int $maxAge = 3600): bool
+    public static function validatePost(string $formName = 'default', string $fieldName = 'csrf_token', int $maxAge = 3600, bool $consumeToken = true): bool
     {
         if (!isset($_POST[$fieldName])) {
             return false;
         }
 
-        return self::validateToken($_POST[$fieldName], $formName, $maxAge);
+        return self::validateToken($_POST[$fieldName], $formName, $maxAge, $consumeToken);
     }
 
     /**

+ 380 - 0
core/class/loginSecurity.class.php

@@ -0,0 +1,380 @@
+<?php
+
+/**
+ * Classe loginSecurity
+ * 
+ * Gère la sécurité avancée pour la page de login :
+ * - Rate limiting (limitation du nombre de tentatives)
+ * - Throttling (délai progressif après échecs)
+ * - Logging des tentatives
+ * - Verrouillage temporaire
+ */
+class loginSecurity
+{
+    // Configuration
+    private const MAX_ATTEMPTS = 5;           // Nombre max de tentatives avant blocage
+    private const LOCKOUT_TIME = 900;         // Durée du blocage en secondes (15 min)
+    private const THROTTLE_BASE = 2;          // Délai de base en secondes
+    private const THROTTLE_MAX = 30;          // Délai maximum en secondes
+    private const LOG_TABLE = 'login_attempts'; // Table de log (si DB)
+
+    /**
+     * Vérifie si l'IP ou l'email est bloqué
+     * 
+     * @param string $email Email tenté
+     * @param string|null $ip Adresse IP (auto-détectée si null)
+     * @return array ['blocked' => bool, 'remaining_time' => int, 'attempts' => int]
+     */
+    public static function checkBlocked(string $email = '', ?string $ip = null): array
+    {
+        $ip = $ip ?? self::getClientIP();
+        $key = self::getCacheKey($ip, $email);
+
+        // Récupérer les données de session
+        $data = $_SESSION['login_security'][$key] ?? [
+            'attempts' => 0,
+            'last_attempt' => 0,
+            'locked_until' => 0
+        ];
+
+        // Vérifier si bloqué
+        if ($data['locked_until'] > time()) {
+            return [
+                'blocked' => true,
+                'remaining_time' => $data['locked_until'] - time(),
+                'attempts' => $data['attempts'],
+                'message' => self::getBlockedMessage($data['locked_until'] - time())
+            ];
+        }
+
+        // Réinitialiser si le blocage est expiré
+        if ($data['locked_until'] > 0 && $data['locked_until'] <= time()) {
+            self::resetAttempts($ip, $email);
+            $data['attempts'] = 0;
+        }
+
+        return [
+            'blocked' => false,
+            'remaining_time' => 0,
+            'attempts' => $data['attempts'],
+            'message' => ''
+        ];
+    }
+
+    /**
+     * Enregistre une tentative de connexion
+     * 
+     * @param string $email Email tenté
+     * @param bool $success Tentative réussie ou non
+     * @param string|null $ip Adresse IP
+     * @return array Statut après la tentative
+     */
+    public static function recordAttempt(string $email, bool $success, ?string $ip = null): array
+    {
+        $ip = $ip ?? self::getClientIP();
+        $key = self::getCacheKey($ip, $email);
+
+        // Log dans la base de données
+        self::logAttempt($email, $ip, $success);
+
+        // Si succès, réinitialiser les compteurs
+        if ($success) {
+            self::resetAttempts($ip, $email);
+            return [
+                'blocked' => false,
+                'attempts' => 0,
+                'throttle_delay' => 0
+            ];
+        }
+
+        // Incrémenter le compteur d'échecs
+        $data = $_SESSION['login_security'][$key] ?? [
+            'attempts' => 0,
+            'last_attempt' => 0,
+            'locked_until' => 0
+        ];
+
+        $data['attempts']++;
+        $data['last_attempt'] = time();
+
+        // Vérifier si on doit bloquer
+        if ($data['attempts'] >= self::MAX_ATTEMPTS) {
+            $data['locked_until'] = time() + self::LOCKOUT_TIME;
+            $_SESSION['login_security'][$key] = $data;
+
+            // Log le blocage
+            error_log(sprintf(
+                "[SECURITY] IP %s blocked for %d seconds after %d failed attempts (email: %s)",
+                $ip,
+                self::LOCKOUT_TIME,
+                $data['attempts'],
+                $email
+            ));
+
+            return [
+                'blocked' => true,
+                'attempts' => $data['attempts'],
+                'throttle_delay' => 0,
+                'remaining_time' => self::LOCKOUT_TIME,
+                'message' => self::getBlockedMessage(self::LOCKOUT_TIME)
+            ];
+        }
+
+        $_SESSION['login_security'][$key] = $data;
+
+        // Calculer le délai de throttling
+        $throttleDelay = self::calculateThrottleDelay($data['attempts']);
+
+        return [
+            'blocked' => false,
+            'attempts' => $data['attempts'],
+            'remaining_attempts' => self::MAX_ATTEMPTS - $data['attempts'],
+            'throttle_delay' => $throttleDelay,
+            'message' => self::getWarningMessage($data['attempts'])
+        ];
+    }
+
+    /**
+     * Applique le délai de throttling (à appeler avant de traiter le login)
+     * 
+     * @param string $email Email tenté
+     * @param string|null $ip Adresse IP
+     */
+    public static function applyThrottle(string $email = '', ?string $ip = null): void
+    {
+        $ip = $ip ?? self::getClientIP();
+        $key = self::getCacheKey($ip, $email);
+
+        $data = $_SESSION['login_security'][$key] ?? ['attempts' => 0];
+
+        if ($data['attempts'] > 0) {
+            $delay = self::calculateThrottleDelay($data['attempts']);
+            if ($delay > 0) {
+                sleep($delay);
+            }
+        }
+    }
+
+    /**
+     * Calcule le délai de throttling progressif
+     * 
+     * @param int $attempts Nombre de tentatives
+     * @return int Délai en secondes
+     */
+    private static function calculateThrottleDelay(int $attempts): int
+    {
+        if ($attempts <= 1) {
+            return 0;
+        }
+
+        // Délai exponentiel : 2, 4, 8, 16, 30 (max)
+        $delay = pow(self::THROTTLE_BASE, $attempts - 1);
+        return min($delay, self::THROTTLE_MAX);
+    }
+
+    /**
+     * Réinitialise les tentatives pour une IP/email
+     * 
+     * @param string $ip Adresse IP
+     * @param string $email Email
+     */
+    public static function resetAttempts(string $ip, string $email = ''): void
+    {
+        $key = self::getCacheKey($ip, $email);
+        unset($_SESSION['login_security'][$key]);
+
+        // Aussi réinitialiser par IP seule
+        $keyIp = self::getCacheKey($ip, '');
+        unset($_SESSION['login_security'][$keyIp]);
+    }
+
+    /**
+     * Log une tentative dans la base de données
+     * 
+     * @param string $email Email tenté
+     * @param string $ip Adresse IP
+     * @param bool $success Succès ou échec
+     */
+    private static function logAttempt(string $email, string $ip, bool $success): void
+    {
+        try {
+            // Vérifier si la table existe
+            $tableExists = db::bind("SHOW TABLES LIKE ?", [self::LOG_TABLE]);
+
+            if (!empty($tableExists)) {
+                db::bind(
+                    "INSERT INTO " . self::LOG_TABLE . " (email, ip_address, success, user_agent, attempted_at) 
+                     VALUES (?, ?, ?, ?, NOW())",
+                    [
+                        $email,
+                        $ip,
+                        $success ? 1 : 0,
+                        $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown'
+                    ]
+                );
+            }
+        } catch (Exception $e) {
+            // Si la table n'existe pas, log dans le fichier
+            error_log(sprintf(
+                "[LOGIN] %s | IP: %s | Email: %s | Status: %s | UA: %s",
+                date('Y-m-d H:i:s'),
+                $ip,
+                $email,
+                $success ? 'SUCCESS' : 'FAILED',
+                $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown'
+            ));
+        }
+    }
+
+    /**
+     * Génère la clé de cache unique
+     * 
+     * @param string $ip Adresse IP
+     * @param string $email Email
+     * @return string Clé de cache
+     */
+    private static function getCacheKey(string $ip, string $email): string
+    {
+        // Combiner IP + email hashé pour plus de sécurité
+        $emailHash = $email ? md5(strtolower(trim($email))) : 'nomail';
+        return "login_{$ip}_{$emailHash}";
+    }
+
+    /**
+     * Récupère l'IP réelle du client
+     * 
+     * @return string Adresse IP
+     */
+    public static function getClientIP(): string
+    {
+        $headers = [
+            'HTTP_CF_CONNECTING_IP', // Cloudflare
+            'HTTP_X_FORWARDED_FOR',
+            'HTTP_X_FORWARDED',
+            'HTTP_X_CLUSTER_CLIENT_IP',
+            'HTTP_FORWARDED_FOR',
+            'HTTP_FORWARDED',
+            'HTTP_CLIENT_IP',
+            'REMOTE_ADDR'
+        ];
+
+        foreach ($headers as $header) {
+            if (!empty($_SERVER[$header])) {
+                $ip = $_SERVER[$header];
+                if (strpos($ip, ',') !== false) {
+                    $ips = explode(',', $ip);
+                    $ip = trim($ips[0]);
+                }
+                if (filter_var($ip, FILTER_VALIDATE_IP)) {
+                    return $ip;
+                }
+            }
+        }
+
+        return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
+    }
+
+    /**
+     * Message de blocage formaté
+     * 
+     * @param int $seconds Secondes restantes
+     * @return string Message
+     */
+    private static function getBlockedMessage(int $seconds): string
+    {
+        $minutes = ceil($seconds / 60);
+        if ($minutes <= 1) {
+            return "Trop de tentatives. Réessayez dans $seconds secondes.";
+        }
+        return "Trop de tentatives. Réessayez dans $minutes minutes.";
+    }
+
+    /**
+     * Message d'avertissement
+     * 
+     * @param int $attempts Nombre de tentatives
+     * @return string Message
+     */
+    private static function getWarningMessage(int $attempts): string
+    {
+        $remaining = self::MAX_ATTEMPTS - $attempts;
+        if ($remaining <= 2) {
+            return "Attention : $remaining tentative(s) restante(s) avant blocage.";
+        }
+        return '';
+    }
+
+    /**
+     * Vérifie un honeypot (champ caché anti-bot)
+     * 
+     * @param string $fieldName Nom du champ honeypot
+     * @return bool True si le honeypot est vide (utilisateur légitime)
+     */
+    public static function checkHoneypot(string $fieldName = 'website'): bool
+    {
+        // Le champ honeypot doit être VIDE
+        // Les bots le remplissent souvent automatiquement
+        if (isset($_POST[$fieldName]) && !empty($_POST[$fieldName])) {
+            error_log(sprintf(
+                "[SECURITY] Honeypot triggered | IP: %s | Field: %s | Value: %s",
+                self::getClientIP(),
+                $fieldName,
+                substr($_POST[$fieldName], 0, 50)
+            ));
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Obtient les statistiques de tentatives pour une IP
+     * 
+     * @param string|null $ip Adresse IP
+     * @return array Statistiques
+     */
+    public static function getStats(?string $ip = null): array
+    {
+        $ip = $ip ?? self::getClientIP();
+
+        $stats = [
+            'ip' => $ip,
+            'total_attempts' => 0,
+            'blocked_keys' => []
+        ];
+
+        if (isset($_SESSION['login_security'])) {
+            foreach ($_SESSION['login_security'] as $key => $data) {
+                if (strpos($key, $ip) !== false) {
+                    $stats['total_attempts'] += $data['attempts'] ?? 0;
+                    if (isset($data['locked_until']) && $data['locked_until'] > time()) {
+                        $stats['blocked_keys'][] = $key;
+                    }
+                }
+            }
+        }
+
+        return $stats;
+    }
+
+    /**
+     * Nettoie les anciennes entrées de session
+     */
+    public static function cleanup(): void
+    {
+        if (!isset($_SESSION['login_security'])) {
+            return;
+        }
+
+        $now = time();
+        foreach ($_SESSION['login_security'] as $key => $data) {
+            // Supprimer les entrées expirées depuis plus d'1 heure
+            if (isset($data['locked_until']) && $data['locked_until'] > 0) {
+                if ($data['locked_until'] + 3600 < $now) {
+                    unset($_SESSION['login_security'][$key]);
+                }
+            } elseif (isset($data['last_attempt']) && $data['last_attempt'] + 3600 < $now) {
+                unset($_SESSION['login_security'][$key]);
+            }
+        }
+    }
+}

+ 9 - 2
core/class/pwa.class.php

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Classe pwa
  * 
@@ -18,7 +19,7 @@ class pwa
      */
     private static function getManifeste()
     {
-        if(!empty($_SERVER['HTTP_HOST'])){
+        if (!empty($_SERVER['HTTP_HOST'])) {
             switch ($_SERVER['HTTP_HOST']) {
                 case DOMAIN_CONTROL:
                     return "manifest-control.json";
@@ -40,7 +41,13 @@ class pwa
      */
     public static function printServiceWorker()
     {
-        echo '<script>';
+        // Récupérer le nonce CSP si disponible
+        $nonce = '';
+        if (class_exists('securityHeaders') && method_exists('securityHeaders', 'getNonce')) {
+            $nonce = ' nonce="' . securityHeaders::getNonce() . '"';
+        }
+
+        echo '<script' . $nonce . '>';
         if (PWA == 1) {
             echo '  if("serviceWorker" in navigator){
                         navigator.serviceWorker.register("/' . self::$serviceWorker . '");

+ 303 - 0
core/class/securityHeaders.class.php

@@ -0,0 +1,303 @@
+<?php
+
+/**
+ * Classe securityHeaders
+ * 
+ * Gère les en-têtes HTTP de sécurité
+ */
+class securityHeaders
+{
+    /**
+     * Applique tous les headers de sécurité recommandés
+     * 
+     * @param array $options Options de configuration
+     */
+    public static function apply(array $options = []): void
+    {
+        $defaults = [
+            'csp' => true,
+            'xframe' => true,
+            'xcontent' => true,
+            'xss' => true,
+            'referrer' => true,
+            'hsts' => true,
+            'permissions' => true
+        ];
+
+        $options = array_merge($defaults, $options);
+
+        // Ne pas envoyer si les headers sont déjà envoyés
+        if (headers_sent()) {
+            return;
+        }
+
+        if ($options['xframe']) {
+            self::setXFrameOptions();
+        }
+
+        if ($options['xcontent']) {
+            self::setXContentTypeOptions();
+        }
+
+        if ($options['xss']) {
+            self::setXSSProtection();
+        }
+
+        if ($options['referrer']) {
+            self::setReferrerPolicy();
+        }
+
+        if ($options['hsts'] && self::isHTTPS()) {
+            self::setHSTS();
+        }
+
+        if ($options['permissions']) {
+            self::setPermissionsPolicy();
+        }
+
+        if ($options['csp']) {
+            self::setCSP();
+        }
+    }
+
+    /**
+     * Applique les headers spécifiques pour la page de login
+     */
+    public static function applyForLogin(): void
+    {
+        if (headers_sent()) {
+            return;
+        }
+
+        // Headers de base
+        self::setXFrameOptions('DENY'); // Plus strict pour le login
+        self::setXContentTypeOptions();
+        self::setXSSProtection();
+        self::setReferrerPolicy('no-referrer');
+
+        if (self::isHTTPS()) {
+            self::setHSTS();
+        }
+
+        // CSP strict pour le login
+        self::setLoginCSP();
+
+        // Cache control - Ne pas mettre en cache la page de login
+        self::setNoCache();
+    }
+
+    /**
+     * X-Frame-Options : Empêche le clickjacking
+     * 
+     * @param string $value DENY, SAMEORIGIN, ou ALLOW-FROM uri
+     */
+    public static function setXFrameOptions(string $value = 'SAMEORIGIN'): void
+    {
+        header("X-Frame-Options: $value");
+    }
+
+    /**
+     * X-Content-Type-Options : Empêche le MIME sniffing
+     */
+    public static function setXContentTypeOptions(): void
+    {
+        header('X-Content-Type-Options: nosniff');
+    }
+
+    /**
+     * X-XSS-Protection : Protection XSS du navigateur (legacy)
+     */
+    public static function setXSSProtection(): void
+    {
+        header('X-XSS-Protection: 1; mode=block');
+    }
+
+    /**
+     * Referrer-Policy : Contrôle les informations de referrer
+     * 
+     * @param string $policy Politique de referrer
+     */
+    public static function setReferrerPolicy(string $policy = 'strict-origin-when-cross-origin'): void
+    {
+        header("Referrer-Policy: $policy");
+    }
+
+    /**
+     * Strict-Transport-Security : Force HTTPS
+     * 
+     * @param int $maxAge Durée en secondes (défaut: 1 an)
+     * @param bool $includeSubDomains Inclure les sous-domaines
+     */
+    public static function setHSTS(int $maxAge = 31536000, bool $includeSubDomains = true): void
+    {
+        $header = "Strict-Transport-Security: max-age=$maxAge";
+        if ($includeSubDomains) {
+            $header .= '; includeSubDomains';
+        }
+        header($header);
+    }
+
+    /**
+     * Permissions-Policy : Contrôle l'accès aux APIs du navigateur
+     */
+    public static function setPermissionsPolicy(): void
+    {
+        header("Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=()");
+    }
+
+    /**
+     * Content-Security-Policy : Politique de sécurité du contenu
+     * 
+     * @param array $directives Directives CSP personnalisées
+     */
+    public static function setCSP(array $directives = []): void
+    {
+        $defaults = [
+            "default-src" => "'self'",
+            "script-src" => "'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com",
+            "style-src" => "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com",
+            "font-src" => "'self' https://fonts.gstatic.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com",
+            "img-src" => "'self' data: https:",
+            "connect-src" => "'self'",
+            "frame-ancestors" => "'self'",
+            "form-action" => "'self'",
+            "base-uri" => "'self'"
+        ];
+
+        $directives = array_merge($defaults, $directives);
+
+        $csp = [];
+        foreach ($directives as $directive => $value) {
+            $csp[] = "$directive $value";
+        }
+
+        header("Content-Security-Policy: " . implode('; ', $csp));
+    }
+
+    /**
+     * CSP strict pour la page de login
+     */
+    public static function setLoginCSP(): void
+    {
+        $nonce = self::generateNonce();
+
+        $directives = [
+            "default-src" => "'self'",
+            "script-src" => "'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com",
+            "style-src" => "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com",
+            "font-src" => "'self' https://fonts.gstatic.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com",
+            "img-src" => "'self' data:",
+            "connect-src" => "'self'",
+            "frame-ancestors" => "'none'",
+            "form-action" => "'self'",
+            "base-uri" => "'self'",
+            "object-src" => "'none'"
+        ];
+
+        $csp = [];
+        foreach ($directives as $directive => $value) {
+            $csp[] = "$directive $value";
+        }
+
+        header("Content-Security-Policy: " . implode('; ', $csp));
+    }
+
+    /**
+     * Headers pour désactiver le cache
+     */
+    public static function setNoCache(): void
+    {
+        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
+        header('Cache-Control: post-check=0, pre-check=0', false);
+        header('Pragma: no-cache');
+        header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
+    }
+
+    /**
+     * Génère un nonce pour CSP
+     * 
+     * @return string Nonce encodé en base64
+     */
+    public static function generateNonce(): string
+    {
+        if (!isset($_SESSION['csp_nonce'])) {
+            $_SESSION['csp_nonce'] = base64_encode(random_bytes(16));
+        }
+        return $_SESSION['csp_nonce'];
+    }
+
+    /**
+     * Retourne le nonce actuel pour l'utiliser dans les scripts inline
+     * 
+     * @return string Nonce pour attribut nonce=""
+     */
+    public static function getNonce(): string
+    {
+        return $_SESSION['csp_nonce'] ?? self::generateNonce();
+    }
+
+    /**
+     * Vérifie si la connexion est HTTPS
+     * 
+     * @return bool
+     */
+    private static function isHTTPS(): bool
+    {
+        if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
+            return true;
+        }
+        if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
+            return true;
+        }
+        if (isset($_SERVER['HTTP_CF_VISITOR'])) {
+            $visitor = json_decode($_SERVER['HTTP_CF_VISITOR'], true);
+            if (isset($visitor['scheme']) && $visitor['scheme'] === 'https') {
+                return true;
+            }
+        }
+        if (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] === '443') {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Applique les headers de sécurité pour les API JSON
+     */
+    public static function applyForAPI(): void
+    {
+        if (headers_sent()) {
+            return;
+        }
+
+        header('Content-Type: application/json; charset=utf-8');
+        self::setXContentTypeOptions();
+        self::setNoCache();
+
+        // CORS si nécessaire
+        // self::setCORS();
+    }
+
+    /**
+     * Configure CORS (Cross-Origin Resource Sharing)
+     * 
+     * @param array $allowedOrigins Origines autorisées
+     * @param array $allowedMethods Méthodes autorisées
+     */
+    public static function setCORS(array $allowedOrigins = [], array $allowedMethods = ['GET', 'POST']): void
+    {
+        $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
+
+        // Si pas de restriction, n'autoriser que same-origin
+        if (empty($allowedOrigins)) {
+            return;
+        }
+
+        if (in_array($origin, $allowedOrigins) || in_array('*', $allowedOrigins)) {
+            header("Access-Control-Allow-Origin: $origin");
+            header('Access-Control-Allow-Credentials: true');
+            header('Access-Control-Allow-Methods: ' . implode(', ', $allowedMethods));
+            header('Access-Control-Allow-Headers: Content-Type, Authorization, X-CSRF-Token');
+        }
+    }
+}

+ 19 - 4
core/submit/cms.login.php

@@ -1,9 +1,13 @@
 <?php
 
-if (core::ifPost("from") AND core::getPost("from") == "login") {
+if (core::ifPost("from") and core::getPost("from") == "login") {
+    $email = core::getPost('email') ?? '';
     $connect = user::connect(core::getPost());
 
-    if($connect == TRUE){
+    if ($connect == TRUE) {
+        // Connexion réussie - réinitialiser les compteurs de sécurité
+        loginSecurity::recordAttempt($email, true);
+
         historique::recRef("/login.html");
         historique::add(array(
             "idType" => historique::getIdRef("CONNEXION"),
@@ -11,11 +15,22 @@ if (core::ifPost("from") AND core::getPost("from") == "login") {
             "idPage" => historique::getIdRef("/login.html"),
             "log" => $_SERVER['REMOTE_ADDR']
         ));
+    } else {
+        // Connexion échouée - enregistrer la tentative
+        $result = loginSecurity::recordAttempt($email, false);
+
+        // Si bloqué après cette tentative, rediriger avec message
+        if ($result['blocked']) {
+            alert::recError($result['message']);
+        } elseif (!empty($result['message'])) {
+            // Avertissement (X tentatives restantes)
+            alert::recError($result['message']);
+        }
     }
-    
+
     header("Location: /");
     exit();
 } else {
     header('HTTP/1.0 401 Unauthorized');
     exit();
-}
+}

+ 76 - 57
core/views/_cms.head.php

@@ -1,63 +1,82 @@
 <!DOCTYPE html>
 <html lang="fr">
-    <head>
-        <meta charset="UTF-8">
-        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-        <title>CSE Invent :. CMS<?php debug::printEnvironnement() ?></title>
-
-        <?php pwa::printManifeste(); ?>
-
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/assets/dist/css/bootstrap.min.css") ?>">
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/css/bootstrap-icons.min.css") ?>">
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/css/bootstrap-table.min.css") ?>">
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/inputTags/inputTags.css") ?>">
-        
-        <script src="<?php cache::printFileWithTime("libs/js/jquery.min.js") ?>"></script>
-        <script src="<?php cache::printFileWithTime("libs/bootstrap/js/bootstrap.min.js") ?>"></script>
-
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("css/dashboard.css") ?>"> 
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("css/cms.css") ?>">
-        <link rel="icon" type="image/x-icon" href="<?php cache::printFileWithTime("favicon.ico") ?>">
-        <meta name="robots" content="noindex">
-        
-        <script src="<?php cache::printFileWithTime("libs/js/modernizr.min.js") ?>" type="text/javascript"></script>
-        <script src="<?php cache::printFileWithTime("libs/js/Chart.min.js") ?>" type="text/javascript"></script>
-        <script src="<?php cache::printFileWithTime("libs/js/powerbuttons.min.js") ?>"></script>
-        <script src="<?php cache::printFileWithTime("libs/inputTags/inputTags.jquery.min.js") ?>"></script>
-
-        <?php debug::includeDebug(); ?>
-
-        <style>
-
-            .bd-placeholder-img {
-                font-size: 1.125rem;
-                text-anchor: middle;
-                -webkit-user-select: none;
-                -moz-user-select: none;
-                user-select: none;
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>CSE Invent :. CMS<?php debug::printEnvironnement() ?></title>
+
+    <?php pwa::printManifeste(); ?>
+
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/assets/dist/css/bootstrap.min.css") ?>">
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/css/bootstrap-icons.min.css") ?>">
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/css/bootstrap-table.min.css") ?>">
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/inputTags/inputTags.css") ?>">
+
+    <script src="<?php cache::printFileWithTime("libs/js/jquery.min.js") ?>"></script>
+    <script src="<?php cache::printFileWithTime("libs/bootstrap/js/bootstrap.min.js") ?>"></script>
+
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("css/dashboard.css") ?>">
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("css/cms.css") ?>">
+    <link rel="icon" type="image/x-icon" href="<?php cache::printFileWithTime("favicon.ico") ?>">
+    <meta name="robots" content="noindex">
+
+    <!-- Token CSRF pour les requêtes AJAX -->
+    <?php echo csrf::metaTag('cms-ajax'); ?>
+
+    <script src="<?php cache::printFileWithTime("libs/js/modernizr.min.js") ?>" type="text/javascript"></script>
+    <script src="<?php cache::printFileWithTime("libs/js/Chart.min.js") ?>" type="text/javascript"></script>
+    <script src="<?php cache::printFileWithTime("libs/js/powerbuttons.min.js") ?>"></script>
+    <script src="<?php cache::printFileWithTime("libs/inputTags/inputTags.jquery.min.js") ?>"></script>
+
+    <?php debug::includeDebug(); ?>
+
+    <style>
+        .bd-placeholder-img {
+            font-size: 1.125rem;
+            text-anchor: middle;
+            -webkit-user-select: none;
+            -moz-user-select: none;
+            user-select: none;
+        }
+
+        @media (min-width: 768px) {
+            .bd-placeholder-img-lg {
+                font-size: 3.5rem;
             }
+        }
+    </style>
 
-            @media (min-width: 768px) {
-                .bd-placeholder-img-lg {
-                    font-size: 3.5rem;
+    <!-- Configuration globale CSRF pour jQuery AJAX -->
+    <script>
+        $(document).ready(function() {
+            // Ajouter automatiquement le token CSRF à toutes les requêtes AJAX POST
+            $(document).ajaxSend(function(event, jqxhr, settings) {
+                if (settings.type === 'POST') {
+                    const csrfToken = $('meta[name="csrf-token"]').attr('content');
+                    if (csrfToken) {
+                        jqxhr.setRequestHeader('X-CSRF-Token', csrfToken);
+                    }
                 }
-            }
-        </style>
-
-    </head>
-    <body>
-        <div id="loading" style="display: none;">Loading ...</div>
-        <?php
-        require_once DIR_PHP_VIEWS."_cms.nav.php";
-        ?>
-        
-        <div class="container-fluid">
-            <div class="row">
-                <?php
-                    require_once DIR_PHP_VIEWS."_cms.menu.php";
-                ?>
+            });
+        });
+    </script>
 
-                <main class="col-md-9 ms-sm-auto col-lg-12 px-md-4 main-content-global" id="main-content">
-    <?php   
-        user::printIsSecur();
-    ?>
+</head>
+
+<body>
+    <div id="loading" style="display: none;">Loading ...</div>
+    <?php
+    require_once DIR_PHP_VIEWS . "_cms.nav.php";
+    ?>
+
+    <div class="container-fluid">
+        <div class="row">
+            <?php
+            require_once DIR_PHP_VIEWS . "_cms.menu.php";
+            ?>
+
+            <main class="col-md-9 ms-sm-auto col-lg-12 px-md-4 main-content-global" id="main-content">
+                <?php
+                user::printIsSecur();
+                ?>

+ 3 - 0
core/views/_events.head.php

@@ -11,6 +11,9 @@
         <link rel="stylesheet" href="libs/bootstrap/css/bootstrap-icons.min.css">
         <link rel="stylesheet" href="libs/qrcode-reader/css/qrcode-reader.css">
         <meta name="robots" content="noindex">
+
+        <!-- Token CSRF pour les requêtes AJAX -->
+        <?php echo csrf::metaTag('events-ajax'); ?>
 </head>
 
 <body class=" bg-info">

+ 2 - 1
core/views/_events.meta.php

@@ -15,6 +15,7 @@
 <meta name="robots" content="noindex">
 
 <link rel="stylesheet" href="<?php cache::printFileWithTime("css/general.css") ?>">
-<script src="<?php cache::printFileWithTime("libs/js/jquery.min.js") ?>"></script>    
+<script src="<?php cache::printFileWithTime("libs/js/jquery.min.js") ?>"></script>
+<script src="<?php cache::printFileWithTime("js/csrf-config.js") ?>"></script>
 <script src="<?php cache::printFileWithTime("js/tools.js") ?>"></script>
 <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/assets/dist/css/bootstrap.min.css") ?>">

+ 137 - 71
core/views/pages/cms.login.php

@@ -1,80 +1,146 @@
-<html>
-    <head>
-        <meta charset="UTF-8">
-        <title>CSE Invent : Identification</title>
-
-        <?php pwa::printManifeste(); ?>
-        
-        <meta name="mobile-web-app-capable" content="yes">
-        <meta name="apple-mobile-web-app-capable" content="yes">
-        <meta name="application-name" content="CSE Invent : CMS">
-        <meta name="apple-mobile-web-app-title" content="CSE Invent : CMS">
-        <meta name="msapplication-starturl" content="/">
-        <meta name="msapplication-TileColor" content="#da532c">
-        <meta name="theme-color" content="#ffffff">
-
-        <link rel="icon" type="image/x-icon" href="<?php cache::printFileWithTime("favicon.ico") ?>">
-
-        <script src="<?php cache::printFileWithTime("libs/js/jquery.min.js") ?>"></script>
-        <script src="<?php cache::printFileWithTime("libs/bootstrap/js/bootstrap.min.js") ?>"></script>
-        
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/assets/dist/css/bootstrap.min.css") ?>">
-        <link rel="stylesheet" href="<?php cache::printFileWithTime("css/login.css") ?>">
-    </head>
-    <body>
-
-        <div class="wrapper fadeInDown">
-            
-            <div id="formContent">
-                
-                <div class="fadeIn first">
-                    <img src="img/logo.png" id="icon" alt="CSE Invent" />
-                </div>
+<?php
+// Appliquer les headers de sécurité pour la page de login
+securityHeaders::applyForLogin();
+?>
+<!DOCTYPE html>
+<html lang="fr">
+
+<head>
+    <meta charset="UTF-8">
+    <title>CSE Invent : Identification</title>
+
+    <?php pwa::printManifeste(); ?>
+
+    <meta name="mobile-web-app-capable" content="yes">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="application-name" content="CSE Invent : CMS">
+    <meta name="apple-mobile-web-app-title" content="CSE Invent : CMS">
+    <meta name="msapplication-starturl" content="/">
+    <meta name="msapplication-TileColor" content="#da532c">
+    <meta name="theme-color" content="#ffffff">
+
+    <link rel="icon" type="image/x-icon" href="<?php cache::printFileWithTime("favicon.ico") ?>">
+
+    <!-- Token CSRF pour les requêtes AJAX -->
+    <?php echo csrf::metaTag('login-ajax'); ?>
+
+    <script src="<?php cache::printFileWithTime("libs/js/jquery.min.js") ?>"></script>
+    <script src="<?php cache::printFileWithTime("libs/bootstrap/js/bootstrap.min.js") ?>"></script>
+
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("libs/bootstrap/assets/dist/css/bootstrap.min.css") ?>">
+    <link rel="stylesheet" href="<?php cache::printFileWithTime("css/login.css") ?>">
+</head>
+
+<body>
+
+    <div class="wrapper fadeInDown">
+
+        <div id="formContent">
+
+            <div class="fadeIn first">
+                <img src="img/logo.png" id="icon" alt="CSE Invent" />
+            </div>
 
-                <form method="post" action="/submit.php" id="form-authent">
-                    <input type="hidden" name="from" value="login">
-                    <input type="text" class="fadeIn second" name="email" id="email" placeholder="email" required>
-                    <input type="password" class="fadeIn third" name="password" placeholder="mot de passe" required>
-                    <input type="text" class="third" style="display:none;" name="authenticator" id="authenticator" maxlength="6" placeholder="Code Google Authenticator">
-                    <input type="button" class="fadeIn fourth" id="submit-authent" value="Se connecter">
-                </form>
-                
-                <div id="formFooter"<?php if(!alert::ifError()) { echo ' style="display:none"'; } ?>>
-                    <div class="alert alert-danger" role="alert"><?php if(alert::ifError()) { echo alert::printAlert(alert::getError()); } ?></div>
+            <form method="post" action="/submit.php" id="form-authent">
+                <?php echo csrf::inputField('login-form'); ?>
+                <input type="hidden" name="from" value="login">
+
+                <!-- Honeypot anti-bot (champ invisible) -->
+                <div style="position:absolute;left:-9999px;" aria-hidden="true">
+                    <input type="text" name="website" tabindex="-1" autocomplete="off">
                 </div>
 
-                <script>
-                        $("#submit-authent").on("click", function() {
-                            var formData = {
-                                email: $("#email").val(),
-                                from: "authenticator",
-                            };
-
-                            $.ajax({
-                                type: "POST",
-                                url: "submit.php",
-                                data: formData,
-                                dataType: "json",
-                                encode: true,
-                            }).done(function (data) {
-                                if(data == 1){ 
-                                    $("#authenticator").show();
-                                    $("#authenticator").prop("required", true);
-                                    $('#submit-authent').attr('type', 'submit');
-                                } else {   
-                                    $("#form-authent").submit();
+                <input type="text" class="fadeIn second" name="email" id="email" placeholder="email" required autocomplete="username">
+                <input type="password" class="fadeIn third" name="password" placeholder="mot de passe" required autocomplete="current-password">
+                <input type="text" class="third" style="display:none;" name="authenticator" id="authenticator" maxlength="6" placeholder="Code Google Authenticator">
+                <input type="button" class="fadeIn fourth" id="submit-authent" value="Se connecter">
+
+                <!-- Message de rate limiting -->
+                <div id="rate-limit-warning" class="alert alert-warning" style="display:none;margin:10px;"></div>
+            </form>
+
+            <div id="formFooter" <?php if (!alert::ifError()) {
+                                        echo ' style="display:none"';
+                                    } ?>>
+                <div class="alert alert-danger" role="alert"><?php if (alert::ifError()) {
+                                                                    echo alert::printAlert(alert::getError());
+                                                                } ?></div>
+            </div>
+
+            <script nonce="<?php echo securityHeaders::getNonce(); ?>">
+                $(document).ready(function() {
+                    // Configuration CSRF pour les requêtes AJAX
+                    $(document).ajaxSend(function(event, jqxhr, settings) {
+                        if (settings.type === 'POST') {
+                            const csrfToken = $('meta[name="csrf-token"]').attr('content');
+                            if (csrfToken) {
+                                jqxhr.setRequestHeader('X-CSRF-Token', csrfToken);
+                            }
+                        }
+                    });
+
+                    $("#submit-authent").on("click", function() {
+                        var $btn = $(this);
+                        var $warning = $("#rate-limit-warning");
+
+                        // Désactiver le bouton pendant la requête
+                        $btn.prop('disabled', true).val('Vérification...');
+                        $warning.hide();
+
+                        var formData = {
+                            email: $("#email").val(),
+                            from: "authenticator",
+                            csrf_token: $("input[name='csrf_token']").val() // Inclure le token CSRF du formulaire
+                        };
+
+                        $.ajax({
+                            type: "POST",
+                            url: "submit.php",
+                            data: formData,
+                            dataType: "json",
+                            encode: true,
+                        }).done(function(data) {
+                            if (data && data.blocked) {
+                                // Compte bloqué - afficher le message
+                                $warning.html('<strong>⚠️ ' + data.message + '</strong>').show();
+                                $btn.prop('disabled', true).val('Bloqué');
+                                // Réactiver après le délai
+                                if (data.remaining_time) {
+                                    setTimeout(function() {
+                                        $btn.prop('disabled', false).val('Se connecter');
+                                        $warning.hide();
+                                    }, data.remaining_time * 1000);
                                 }
-                            }).fail(function () {
-                                console.log(data);
-                            });
+                            } else if (data == 1 || data.authenticator == 1) {
+                                $("#authenticator").show();
+                                $("#authenticator").prop("required", true);
+                                $('#submit-authent').attr('type', 'submit').val('Se connecter');
+                                $btn.prop('disabled', false);
+                            } else {
+                                $("#form-authent").submit();
+                            }
+                        }).fail(function(jqXHR, textStatus, errorThrown) {
+                            console.error("Erreur AJAX:", textStatus, errorThrown);
+                            $btn.prop('disabled', false).val('Se connecter');
+
+                            // Gérer les erreurs spécifiques
+                            if (jqXHR.status === 429) {
+                                var response = jqXHR.responseJSON || {};
+                                $warning.html('<strong>⚠️ ' + (response.message || 'Trop de tentatives. Veuillez patienter.') + '</strong>').show();
+                            } else if (jqXHR.status === 403) {
+                                $warning.html('<strong>⚠️ Session expirée. Rechargez la page.</strong>').show();
+                            }
                         });
-                </script>
-            </div>
+                    });
+                });
+            </script>
         </div>
-        <?php pwa::printServiceWorker(); ?>
-    </body>
+    </div>
+    <?php pwa::printServiceWorker(); ?>
+</body>
+
 </html>
 
-<?php 
-    alert::destroyAlert();
+<?php
+alert::destroyAlert();
 ?>

+ 28 - 0
public-cms/json.php

@@ -11,5 +11,33 @@ require_once DIR_PHP_LAYOUTS . "cms.session.php";
 
 header('Content-Type: application/json');
 
+// Validation CSRF pour les requêtes JSON POST
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && core::ifGet("jsonData")) {
+    $jsonData = core::getGet("jsonData");
+
+    // Actions sensibles nécessitant une protection CSRF
+    $csrfProtectedActions = [
+        'user-delete',
+        'document-delete',
+        'event-delete',
+        'salarie-delete',
+        'client-update',
+        'tag-update'
+    ];
+
+    if (in_array($jsonData, $csrfProtectedActions)) {
+        if (!csrf::validateHeader('cms-ajax', 'X-CSRF-Token')) {
+            error_log("CSRF validation failed for JSON action: $jsonData from IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
+            http_response_code(403);
+            echo json_encode([
+                'success' => false,
+                'error' => 'csrf_failed',
+                'message' => 'Token de sécurité invalide. Veuillez recharger la page.'
+            ]);
+            exit();
+        }
+    }
+}
+
 get::json();
 get::jsonData();

+ 119 - 2
public-cms/submit.php

@@ -8,9 +8,126 @@ secureSession::start();
 require_once "../access.inc.php";
 require_once DIR_PHP_LAYOUTS . "cms.session.php";
 
+// Validation CSRF pour les formulaires POST (sauf exceptions)
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && core::ifPost("from")) {
+    $from = core::getPost("from");
+
+    // ========================================
+    // PROTECTION LOGIN : Rate Limiting + Honeypot
+    // ========================================
+    $loginActions = ['login', 'authenticator'];
+
+    if (in_array($from, $loginActions)) {
+        $email = core::getPost('email') ?? '';
+
+        // 1. Vérification du honeypot (anti-bot)
+        if (!loginSecurity::checkHoneypot('website')) {
+            error_log("[SECURITY] Bot detected via honeypot | IP: " . loginSecurity::getClientIP());
+            http_response_code(403);
+            header('Content-Type: application/json');
+            echo json_encode(['error' => 'Requête invalide', 'blocked' => true]);
+            exit();
+        }
+
+        // 2. Vérification du rate limiting
+        $blockStatus = loginSecurity::checkBlocked($email);
+
+        if ($blockStatus['blocked']) {
+            error_log("[SECURITY] Blocked login attempt | IP: " . loginSecurity::getClientIP() . " | Email: $email");
+            http_response_code(429); // Too Many Requests
+            header('Content-Type: application/json');
+            echo json_encode([
+                'success' => false,
+                'blocked' => true,
+                'remaining_time' => $blockStatus['remaining_time'],
+                'message' => $blockStatus['message']
+            ]);
+            exit();
+        }
+
+        // 3. Appliquer le throttling (délai progressif)
+        loginSecurity::applyThrottle($email);
+    }
+
+    // Liste des actions nécessitant une protection CSRF (avec consommation du token)
+    $csrfProtectedActions = [
+        'login',
+        'user-edit',
+        'user-password',
+        'parametres-clients',
+        'evenement-fiche',
+        'lottery-fiche',
+        'alertes-emails',
+        'parametres-teams-edit',
+        'rh-import',
+        'restore'
+    ];
+
+    // Actions qui valident le token SANS le consommer (pour les pré-vérifications AJAX)
+    $csrfCheckOnlyActions = [
+        'authenticator' // Vérification Google Auth avant login - ne consomme pas le token
+    ];
+
+    // Si l'action nécessite une protection CSRF
+    if (in_array($from, $csrfProtectedActions)) {
+        $isValid = false;
+
+        // Tentative validation via header (AJAX)
+        if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
+            $isValid = csrf::validateHeader('cms-ajax', 'X-CSRF-Token');
+            if (!$isValid) {
+                $isValid = csrf::validateHeader('login-ajax', 'X-CSRF-Token');
+            }
+        }
+
+        // Tentative validation via POST (formulaire) - consomme le token
+        if (!$isValid && isset($_POST['csrf_token'])) {
+            $isValid = csrf::validatePost('login-form', 'csrf_token', 3600, true);
+        }
+
+        // Si le token est invalide
+        if (!$isValid) {
+            error_log("CSRF validation failed for action: $from from IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
+
+            if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
+                http_response_code(403);
+                header('Content-Type: application/json');
+                echo json_encode([
+                    'success' => false,
+                    'error' => 'csrf_failed',
+                    'message' => 'Token de sécurité invalide ou expiré. Veuillez recharger la page.'
+                ]);
+                exit();
+            } else {
+                alert::recError("Erreur de sécurité. Veuillez réessayer.");
+                header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/'));
+                exit();
+            }
+        }
+    }
+
+    // Si l'action est une pré-vérification (valide sans consommer)
+    if (in_array($from, $csrfCheckOnlyActions)) {
+        $isValid = false;
+
+        // Validation via POST sans consommer le token
+        if (isset($_POST['csrf_token'])) {
+            $isValid = csrf::validatePost('login-form', 'csrf_token', 3600, false); // false = ne pas consommer
+        }
+
+        if (!$isValid) {
+            error_log("CSRF check failed for action: $from from IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
+            http_response_code(403);
+            header('Content-Type: application/json');
+            echo json_encode(['error' => 'Token invalide']);
+            exit();
+        }
+    }
+}
+
 if (debug::isFile("submit") and (core::ifPost() or core::ifFiles())) {
-        core::ifPost() ? debug::logSession(core::getPost()) : NULL;
-        core::ifFiles() ? debug::logSession(core::getFiles()) : NULL;
+    core::ifPost() ? debug::logSession(core::getPost()) : NULL;
+    core::ifFiles() ? debug::logSession(core::getFiles()) : NULL;
 }
 
 get::submit();

+ 16 - 0
public-events/js/csrf-config.js

@@ -0,0 +1,16 @@
+/**
+ * Configuration globale CSRF pour les requêtes AJAX
+ * Ce script ajoute automatiquement le token CSRF à toutes les requêtes POST
+ */
+
+$(document).ready(function () {
+    // Ajouter automatiquement le token CSRF à toutes les requêtes AJAX POST
+    $(document).ajaxSend(function (event, jqxhr, settings) {
+        if (settings.type === 'POST') {
+            const csrfToken = $('meta[name="csrf-token"]').attr('content');
+            if (csrfToken) {
+                jqxhr.setRequestHeader('X-CSRF-Token', csrfToken);
+            }
+        }
+    });
+});

+ 22 - 0
public-events/json.php

@@ -8,5 +8,27 @@ secureSession::start();
 require_once "../access.inc.php";
 require_once DIR_PHP_LAYOUTS . "events.session.php";
 
+// Validation CSRF pour les requêtes JSON POST
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && core::ifGet("jsonData")) {
+    $jsonData = core::getGet("jsonData");
+
+    // Actions nécessitant une protection CSRF
+    $csrfProtectedActions = ['login', 'logout', 'inscription', 'validation'];
+
+    if (in_array($jsonData, $csrfProtectedActions)) {
+        if (!csrf::validateHeader('events-ajax', 'X-CSRF-Token')) {
+            error_log("CSRF validation failed for JSON action: $jsonData from IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
+            http_response_code(403);
+            header('Content-Type: application/json');
+            echo json_encode([
+                'success' => false,
+                'error' => 'csrf_failed',
+                'message' => 'Token de sécurité invalide. Veuillez recharger la page.'
+            ]);
+            exit();
+        }
+    }
+}
+
 get::json();
 get::jsonData();

+ 22 - 0
public-events/submit.php

@@ -8,4 +8,26 @@ secureSession::start();
 require_once "../access.inc.php";
 require_once DIR_PHP_LAYOUTS . "events.session.php";
 
+// Validation CSRF pour les soumissions POST
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    // Validation via header AJAX
+    if (!csrf::validateHeader('events-ajax', 'X-CSRF-Token')) {
+        error_log("CSRF validation failed for events submit from IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
+
+        if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
+            http_response_code(403);
+            header('Content-Type: application/json');
+            echo json_encode([
+                'success' => false,
+                'error' => 'csrf_failed',
+                'message' => 'Token de sécurité invalide. Veuillez recharger la page.'
+            ]);
+            exit();
+        } else {
+            header('Location: /');
+            exit();
+        }
+    }
+}
+
 get::submit();