settings = get_option('devis_pro_settings'); } /** * ============================================= * HONEYPOT - Anti-spam invisible * ============================================= */ /** * Générer le champ honeypot (à inclure dans les formulaires) */ public static function render_honeypot() { $field_name = self::get_honeypot_field_name(); ?>
120 ) { $safe_value = substr( $safe_value, 0, 120 ) . '…'; } self::log_security_event( 'honeypot_triggered', array( 'ip' => self::get_client_ip(), 'value' => $safe_value, ) ); return false; // Bot détecté } return true; // OK } /** * ============================================= * RATE LIMITING - Limite les soumissions par IP * ============================================= */ /** * Vérifier le rate limiting */ public static function check_rate_limit($action = 'form_submit') { // TEMPORAIREMENT DÉSACTIVÉ POUR LES TESTS return true; /* $ip = self::get_client_ip(); $transient_key = 'rdv_rate_' . $action . '_' . md5($ip); $attempts = get_transient($transient_key); if ($attempts === false) { $attempts = 0; } if ($attempts >= self::MAX_SUBMISSIONS_PER_HOUR) { self::log_security_event('rate_limit_exceeded', array( 'ip' => $ip, 'action' => $action, 'attempts' => $attempts )); return false; } // Incrémenter le compteur set_transient($transient_key, $attempts + 1, HOUR_IN_SECONDS); return true; */ } /** * Réinitialiser le rate limit pour une IP (après succès par exemple) */ public static function reset_rate_limit($action = 'form_submit') { $ip = self::get_client_ip(); $transient_key = 'rdv_rate_' . $action . '_' . md5($ip); delete_transient($transient_key); } /** * ============================================= * PROTECTION BRUTE FORCE - Espace client * ============================================= */ /** * Vérifier si l'IP est bloquée */ public static function is_ip_blocked() { $ip = self::get_client_ip(); $lockout_key = 'torlann_rdv_lockout_' . md5($ip); $lockout = get_transient($lockout_key); if ($lockout !== false) { return true; } return false; } /** * Enregistrer une tentative d'accès échouée */ public static function record_failed_attempt($action = 'client_login') { $ip = self::get_client_ip(); $attempts_key = 'torlann_rdv_attempts_' . $action . '_' . md5($ip); $attempts = get_transient($attempts_key); if ($attempts === false) { $attempts = 0; } $attempts++; if ($attempts >= self::MAX_LOGIN_ATTEMPTS) { // Bloquer l'IP $lockout_key = 'torlann_rdv_lockout_' . md5($ip); set_transient($lockout_key, true, self::LOCKOUT_DURATION); self::log_security_event('ip_blocked', array( 'ip' => $ip, 'action' => $action, 'attempts' => $attempts )); // Réinitialiser le compteur delete_transient($attempts_key); } else { set_transient($attempts_key, $attempts, self::LOCKOUT_DURATION); } } /** * Réinitialiser les tentatives après succès */ public static function reset_failed_attempts($action = 'client_login') { $ip = self::get_client_ip(); $attempts_key = 'torlann_rdv_attempts_' . $action . '_' . md5($ip); delete_transient($attempts_key); } /** * ============================================= * TOKENS SÉCURISÉS - Accès espace client * ============================================= */ /** * Générer un token d'accès sécurisé (HMAC durable — pas de transient). * * Format : tl1.{exp}.{hmac} — validable sans object-cache / purge options. * Les anciens tokens hex 64 (transient) restent acceptés jusqu’à expiration. * * @param string $email Client email. * @return string */ public static function generate_access_token($email) { $email = strtolower( sanitize_email( $email ) ); if ( ! $email ) { return ''; } $exp = time() + self::TOKEN_EXPIRY; $sig = hash_hmac( 'sha256', $email . '|' . $exp, wp_salt( 'auth' ) ); return self::ACCESS_TOKEN_HMAC_PREFIX . $exp . '.' . $sig; } /** * Valider un token d'accès * * @param string $token Token from magic link. * @param string $email Email from magic link. * @return bool */ public static function validate_access_token($token, $email) { $token = sanitize_text_field( (string) $token ); $email = strtolower( sanitize_email( $email ) ); if ( '' === $token || '' === $email ) { return false; } if ( 0 === strpos( $token, self::ACCESS_TOKEN_HMAC_PREFIX ) ) { $rest = substr( $token, strlen( self::ACCESS_TOKEN_HMAC_PREFIX ) ); $dot = strpos( $rest, '.' ); if ( false === $dot ) { self::record_failed_attempt( 'token_validation' ); return false; } $exp = (int) substr( $rest, 0, $dot ); $sig = substr( $rest, $dot + 1 ); if ( $exp < time() || ! preg_match( '/^[a-f0-9]{64}$/', $sig ) ) { self::record_failed_attempt( 'token_validation' ); return false; } $expected = hash_hmac( 'sha256', $email . '|' . $exp, wp_salt( 'auth' ) ); if ( ! hash_equals( $expected, $sig ) ) { self::record_failed_attempt( 'token_validation' ); return false; } self::reset_failed_attempts( 'token_validation' ); return true; } // Legacy : sha256 + transient (24h / 7j selon version qui l’a émis). if ( strlen( $token ) !== 64 || ! preg_match( '/^[a-f0-9]{64}$/', $token ) ) { self::record_failed_attempt( 'token_validation' ); return false; } $token_data = get_transient( 'rdv_token_' . $token ); if ( false === $token_data || ! is_array( $token_data ) ) { self::record_failed_attempt( 'token_validation' ); return false; } $stored = strtolower( sanitize_email( (string) ( $token_data['email'] ?? '' ) ) ); if ( $stored !== $email ) { self::record_failed_attempt( 'token_validation' ); return false; } self::reset_failed_attempts( 'token_validation' ); return true; } /** * Invalider un token (legacy transient seulement ; HMAC expire tout seul). * * @param string $token Token. */ public static function invalidate_token($token) { $token = sanitize_text_field( (string) $token ); if ( 0 === strpos( $token, self::ACCESS_TOKEN_HMAC_PREFIX ) ) { return; } delete_transient( 'rdv_token_' . $token ); } /** * ============================================= * VALIDATION DES DONNÉES * ============================================= */ /** * Valider et nettoyer un numéro de téléphone */ public static function validate_phone($phone) { // Supprimer tout sauf chiffres et + $cleaned = preg_replace('/[^0-9+]/', '', $phone); // Vérifier la longueur (min 10 chiffres) if (strlen(preg_replace('/[^0-9]/', '', $cleaned)) < 10) { return false; } return $cleaned; } /** * Valider un nom/prénom (pas de caractères suspects) */ public static function validate_name($name) { // Supprimer les balises HTML $cleaned = wp_strip_all_tags($name); // Vérifier les caractères suspects (injection SQL, XSS) if (preg_match('/[<>{}|\[\]\\\\]/', $cleaned)) { return false; } // Longueur raisonnable if (strlen($cleaned) < 2 || strlen($cleaned) > 50) { return false; } return sanitize_text_field($cleaned); } /** * Valider un code postal français */ public static function validate_postal_code($cp) { $cleaned = preg_replace('/[^0-9]/', '', $cp); // Code postal français = 5 chiffres if (strlen($cleaned) !== 5) { return false; } return $cleaned; } /** * Valider un email de manière stricte */ /** * Le serveur peut-il interroger le DNS ? (résultat mis en cache 12 h) * * @return bool */ private static function dns_lookup_available() { if (!function_exists('checkdnsrr')) { return false; } $cached = get_transient('torlann_dns_lookup_available'); if (false !== $cached) { return '1' === $cached; } $ok = checkdnsrr('wordpress.org', 'A') || checkdnsrr('wordpress.org', 'MX'); set_transient('torlann_dns_lookup_available', $ok ? '1' : '0', 12 * HOUR_IN_SECONDS); return $ok; } public static function validate_email($email) { $email = sanitize_email($email); if (!is_email($email)) { return false; } // Vérifier que le domaine existe (DNS MX / A) — seulement si l’hébergeur sait résoudre le DNS : // sans résolution possible, toutes les adresses étaient refusées et aucune demande n’arrivait. $domain = substr($email, strpos($email, '@') + 1); if (self::dns_lookup_available() && !checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) { return false; } // Liste noire de domaines jetables (optionnel) $disposable_domains = array( 'tempmail.com', 'throwaway.email', 'guerrillamail.com', 'mailinator.com', 'yopmail.com', '10minutemail.com' ); if (in_array($domain, $disposable_domains)) { return false; } return $email; } /** * Nettoyer un message (anti-XSS) */ public static function sanitize_message($message) { // Supprimer les balises HTML $cleaned = wp_strip_all_tags($message); // Échapper les caractères spéciaux $cleaned = htmlspecialchars($cleaned, ENT_QUOTES, 'UTF-8'); // Limiter la longueur if (strlen($cleaned) > 5000) { $cleaned = substr($cleaned, 0, 5000); } return $cleaned; } /** * ============================================= * VÉRIFICATION GLOBALE DU FORMULAIRE * ============================================= */ /** * Effectuer toutes les vérifications de sécurité */ public static function validate_form_submission($post_data) { $errors = array(); // 1. Vérifier si l'IP est bloquée if (self::is_ip_blocked()) { return array( 'valid' => false, 'error' => __('Too many attempts. Please try again in 15 minutes.', 'torlann') ); } // 2. Vérifier le honeypot if (!self::check_honeypot($post_data)) { // Retourner un succès faux pour ne pas alerter le bot return array( 'valid' => false, 'error' => '', 'is_bot' => true ); } // 3. Vérifier le rate limiting if (!self::check_rate_limit('form_submit')) { return array( 'valid' => false, 'error' => __('You have submitted too many requests. Please try again later.', 'torlann') ); } // 4. Valider l'email if (!empty($post_data['email'])) { $email = self::validate_email($post_data['email']); if ($email === false) { $errors['email'] = __('Invalid email address', 'torlann'); } } // 5. Valider le téléphone if (!empty($post_data['tel'])) { $phone = self::validate_phone($post_data['tel']); if ($phone === false) { $errors['tel'] = __('Invalid phone number', 'torlann'); } } // 6. Valider le nom et prénom if (!empty($post_data['nom'])) { $nom = self::validate_name($post_data['nom']); if ($nom === false) { $errors['nom'] = __('Invalid name', 'torlann'); } } if (!empty($post_data['prenom'])) { $prenom = self::validate_name($post_data['prenom']); if ($prenom === false) { $errors['prenom'] = __('Invalid first name', 'torlann'); } } // 7. Valider le code postal (si fourni) if (!empty($post_data['cp'])) { $cp = self::validate_postal_code($post_data['cp']); if ($cp === false) { $errors['cp'] = __('Invalid postal code', 'torlann'); } } if (!empty($errors)) { return array( 'valid' => false, 'errors' => $errors, 'error' => implode(', ', $errors) ); } return array('valid' => true); } /** * ============================================= * RECAPTCHA (Optionnel) * ============================================= */ /** * Vérifier reCAPTCHA v3 */ public static function verify_recaptcha($token, $secret_key) { if (empty($secret_key)) { return true; // reCAPTCHA non configuré } $response = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', array( 'body' => array( 'secret' => $secret_key, 'response' => $token, 'remoteip' => self::get_client_ip() ) )); if (is_wp_error($response)) { return true; // En cas d'erreur, on laisse passer } $result = json_decode(wp_remote_retrieve_body($response), true); // Score minimum de 0.5 pour reCAPTCHA v3 if ($result['success'] && isset($result['score']) && $result['score'] >= 0.5) { return true; } self::log_security_event('recaptcha_failed', array( 'ip' => self::get_client_ip(), 'score' => $result['score'] ?? 0 )); return false; } /** * ============================================= * UTILITAIRES * ============================================= */ /** * Obtenir l'IP du client (avec gestion des proxies) */ public static function get_client_ip() { $ip = ''; if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { // Cloudflare $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ); } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { // Proxy $ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ); $ip = trim($ips[0]); } elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) { $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ); } elseif (!empty($_SERVER['REMOTE_ADDR'])) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } // Valider l'IP if (filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } return '0.0.0.0'; } /** * Logger un événement de sécurité */ private static function log_security_event($event, $data = array()) { if (!defined('WP_DEBUG') || !WP_DEBUG) { return; } $log_data = array_merge( array( 'event' => $event, 'timestamp' => current_time( 'mysql' ), ), $data ); torlann_debug_log( 'Security: ' . wp_json_encode( $log_data ) ); } /** * Ajouter les headers de sécurité */ public static function add_security_headers() { if (headers_sent()) { return; } // Protection XSS header('X-Content-Type-Options: nosniff'); header('X-XSS-Protection: 1; mode=block'); // Protection clickjacking header('X-Frame-Options: SAMEORIGIN'); // Referrer Policy header('Referrer-Policy: strict-origin-when-cross-origin'); } }