Intermédiaire 23 min de lecture · 5 050 mots

Sécurité WordPress : Protéger son site contre les attaques courantes

Sécurité WordPress : Protéger son site contre les attaques courantes
Intermédiaire · 2026.05.09

Introduction

La sécurité WordPress n’est pas optionnelle. Avec plus de 43% des sites web utilisant WordPress, il est devenu une cible privilégiée des attaquants. Chaque jour, des dizaines de milliers de tentatives d’intrusion sont menées contre des sites WordPress mal sécurisés.

Ce guide complet vous apprendra à identifier, comprendre et contrer les vecteurs d’attaque les plus courants, avec des exemples de code production-ready et des stratégies de défense en profondeur.

Table des matières

  • Comprendre le modèle de menaces WordPress
  • Injection SQL et sécurisation des requêtes
  • Cross-Site Scripting (XSS)
  • Cross-Site Request Forgery (CSRF)
  • Authentification et gestion des sessions
  • Upload de fichiers sécurisé
  • Permissions et capabilities
  • Sécurité de l’API REST
  • Hardening du serveur et configuration
  • Monitoring et détection d’intrusions
  • 1. Comprendre le modèle de menaces WordPress

    Les vecteurs d’attaque principaux

┌─────────────────────────────────────────────────┐
│           VECTEURS D'ATTAQUE WORDPRESS          │
├─────────────────────────────────────────────────┤
│                                                 │
│  1. Injection SQL          ████████░░  80%     │
│  2. XSS                    ███████░░░  70%     │
│  3. CSRF                   ██████░░░░  60%     │
│  4. Brute Force            █████████░  90%     │
│  5. File Inclusion         ████░░░░░░  40%     │
│  6. Upload malveillant     █████░░░░░  50%     │
│  7. Vulnérabilités plugins ██████████  95%     │
│                                                 │
└─────────────────────────────────────────────────┘

Architecture de défense en profondeur

<?php
/**
 * Stratégie de sécurité multi-couches
 */
class YAWC_Security_Framework {

    /**
     * Couches de sécurité
     */
    const LAYERS = array(
        'server'         => 'Configuration serveur et firewall',
        'application'    => 'Code WordPress sécurisé',
        'authentication' => 'Authentification forte',
        'authorization'  => 'Contrôle d'accès',
        'validation'     => 'Validation des entrées',
        'sanitization'   => 'Nettoyage des données',
        'escaping'       => 'Échappement des sorties',
        'logging'        => 'Journalisation des événements',
    );

    /**
     * Initialiser toutes les couches de sécurité
     */
    public static function init() {
        // Couche 1: Sécurité serveur
        self::setup_server_security();

        // Couche 2: Sécurité application
        self::setup_application_security();

        // Couche 3: Authentification
        self::setup_authentication_security();

        // Couche 4: Autorisation
        self::setup_authorization_security();

        // Couche 5: Validation & Sanitization
        self::setup_data_security();

        // Couche 6: Logging & Monitoring
        self::setup_monitoring();
    }

    private static function setup_server_security() {
        // Headers de sécurité
        add_action('send_headers', array(__CLASS__, 'add_security_headers'));

        // Désactiver l'affichage des erreurs en production
        if (!WP_DEBUG) {
            ini_set('display_errors', 0);
            ini_set('display_startup_errors', 0);
        }
    }

    public static function add_security_headers() {
        if (!is_admin()) {
            // Protection XSS
            header('X-XSS-Protection: 1; mode=block');

            // Prévenir le MIME sniffing
            header('X-Content-Type-Options: nosniff');

            // Prévenir le clickjacking
            header('X-Frame-Options: SAMEORIGIN');

            // Content Security Policy
            header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';");

            // Référer Policy
            header('Referrer-Policy: strict-origin-when-cross-origin');

            // Permissions Policy
            header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
        }
    }

    private static function setup_application_security() {
        // Désactiver l'édition de fichiers
        if (!defined('DISALLOW_FILE_EDIT')) {
            define('DISALLOW_FILE_EDIT', true);
        }

        // Limiter les révisions
        if (!defined('WP_POST_REVISIONS')) {
            define('WP_POST_REVISIONS', 5);
        }

        // Désactiver le XML-RPC si non utilisé
        add_filter('xmlrpc_enabled', '**return_false');

        // Retirer les informations de version
        remove_action('wp_head', 'wp_generator');
    }

    private static function setup_authentication_security() {
        // Limiter les tentatives de connexion
        add_action('wp_login_failed', array(__CLASS__, 'log_failed_login'));

        // Forcer les mots de passe forts
        add_action('user_profile_update_errors', array(__CLASS__, 'validate_password_strength'), 0, 3);

        // Expirer les sessions inactives
        add_filter('auth_cookie_expiration', array(__CLASS__, 'set_session_timeout'), 10, 3);
    }

    private static function setup_authorization_security() {
        // Vérifier les capacités sur les actions sensibles
        add_action('admin_init', array(**CLASS__, 'check_admin_capabilities'));
    }

    private static function setup_data_security() {
        // Validation et sanitization seront détaillées dans les sections suivantes
    }

    private static function setup_monitoring() {
        // Logging sera détaillé dans la section 10
    }

    public static function log_failed_login($username) {
        $ip = self::get_client_ip();

        error_log(sprintf(
            '[SECURITY] Failed login attempt for user "%s" from IP %s at %s',
            $username,
            $ip,
            current_time('mysql')
        ));

        // Incrémenter le compteur de tentatives
        $attempts_key = 'login_attempts*' . $ip;
        $attempts = get_transient($attempts_key) ?: 0;
        $attempts++;

        set_transient($attempts_key, $attempts, HOUR_IN_SECONDS);

        // Bloquer après 5 tentatives
        if ($attempts >= 5) {
            self::block_ip($ip, 'Too many failed login attempts');
            wp_die('Trop de tentatives de connexion échouées. Votre IP a été temporairement bloquée.');
        }
    }

    public static function validate_password_strength($errors, $update, $user) {
        if (empty($*POST['pass1'])) {
            return;
        }

        $password = $*POST['pass1'];

        // Critères de mot de passe fort
        $strength_requirements = array(
            'length'    => strlen($password) >= 12,
            'uppercase' => preg_match('/[A-Z]/', $password),
            'lowercase' => preg_match('/[a-z]/', $password),
            'number'    => preg_match('/[0-9]/', $password),
            'special'   => preg_match('/[^A-Za-z0-9]/', $password),
        );

        $failed_requirements = array();

        if (!$strength_requirements['length']) {
            $failed_requirements[] = 'au moins 12 caractères';
        }
        if (!$strength_requirements['uppercase']) {
            $failed_requirements[] = 'une majuscule';
        }
        if (!$strength_requirements['lowercase']) {
            $failed_requirements[] = 'une minuscule';
        }
        if (!$strength_requirements['number']) {
            $failed_requirements[] = 'un chiffre';
        }
        if (!$strength_requirements['special']) {
            $failed_requirements[] = 'un caractère spécial';
        }

        if (!empty($failed_requirements)) {
            $errors->add(
                'weak_password',
                sprintf(
                    'Le mot de passe doit contenir %s.',
                    implode(', ', $failed_requirements)
                )
            );
        }

        // Vérifier contre les mots de passe communs
        if (self::is_common_password($password)) {
            $errors->add('common_password', 'Ce mot de passe est trop commun. Veuillez en choisir un autre.');
        }
    }

    private static function is_common_password($password) {
        $common_passwords = array(
            'password', '12345678', 'qwerty', 'abc123', 'password123',
            'admin', 'letmein', 'welcome', 'monkey', '1234567890',
        );

        return in_array(strtolower($password), $common_passwords);
    }

    public static function set_session_timeout($expiration, $user_id, $remember) {
        // 2 heures si "se souvenir de moi" n'est pas coché
        return $remember ? $expiration : 2 * HOUR_IN_SECONDS;
    }

    public static function get_client_ip() {
        $ip_keys = array(
            'HTTP_CLIENT_IP',
            'HTTP_X_FORWARDED_FOR',
            'HTTP_X_FORWARDED',
            'HTTP_X_CLUSTER_CLIENT_IP',
            'HTTP_FORWARDED_FOR',
            'HTTP_FORWARDED',
            'REMOTE_ADDR'
        );

        foreach ($ip_keys as $key) {
            if (array_key_exists($key, $*SERVER) === true) {
                foreach (explode(',', $*SERVER[$key]) as $ip) {
                    $ip = trim($ip);

                    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) {
                        return $ip;
                    }
                }
            }
        }

        return isset($*SERVER['REMOTE_ADDR']) ? $*SERVER['REMOTE_ADDR'] : '0.0.0.0';
    }

    private static function block_ip($ip, $reason) {
        // Stocker l'IP bloquée
        $blocked_ips = get_option('yawc_blocked_ips', array());

        $blocked_ips[$ip] = array(
            'reason'    => $reason,
            'timestamp' => current_time('timestamp'),
            'expires'   => current_time('timestamp') + HOUR_IN_SECONDS,
        );

        update_option('yawc_blocked_ips', $blocked_ips);
    }

    public static function check_admin_capabilities() {
        // Vérifier que l'utilisateur a bien les droits pour les actions sensibles
        if (isset($*GET['action']) && $*GET['action'] === 'delete') {
            if (!current_user_can('delete_posts')) {
                wp_die('Vous n'avez pas les permissions nécessaires.');
            }
        }
    }
}

// Initialiser le framework
add_action('init', array('YAWC_Security_Framework', 'init'));

2. Injection SQL et sécurisation des requêtes

Comprendre les injections SQL

<?php
/**
 * VULNÉRABLE : N'UTILISEZ JAMAIS CE CODE
 */
function yawc_get_user_by_email_vulnerable($email) {
    global $wpdb;

    // DANGER : Injection SQL possible
    $query = "SELECT * FROM {$wpdb->users} WHERE user_email = '$email'";
    $user = $wpdb->get_row($query);

    return $user;
}

// Attaque possible :
// yawc_get_user_by_email_vulnerable("' OR '1'='1' --");
// Résultat : Récupère tous les utilisateurs !

/**
 * SÉCURISÉ : Toujours utiliser prepare()
 */
function yawc_get_user_by_email_secure($email) {
    global $wpdb;

    // Utiliser prepare() pour échapper automatiquement
    $query = $wpdb->prepare(
        "SELECT * FROM {$wpdb->users} WHERE user_email = %s",
        $email
    );

    $user = $wpdb->get_row($query);

    return $user;
}

Requêtes préparées avancées

<?php
/**
 * Exemples de requêtes sécurisées
 */
class YAWC_Secure_Database {

    /**
     * Recherche sécurisée avec LIKE
     */
    public static function search_posts($search_term) {
        global $wpdb;

        // Échapper les caractères spéciaux LIKE
        $search_term = '%' . $wpdb->esc_like($search_term) . '%';

        $query = $wpdb->prepare(
            "SELECT ID, post_title FROM {$wpdb->posts}
             WHERE post_title LIKE %s
             AND post_status = 'publish'
             AND post_type = 'post'
             ORDER BY post_date DESC
             LIMIT 20",
            $search_term
        );

        return $wpdb->get_results($query);
    }

    /**
     * Requête avec plusieurs paramètres
     */
    public static function get_posts_by_author_and_category($author_id, $category_id, $limit = 10) {
        global $wpdb;

        $query = $wpdb->prepare(
            "SELECT p.* FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
             INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
             WHERE p.post_author = %d
             AND tt.term_id = %d
             AND p.post_status = 'publish'
             AND p.post_type = 'post'
             ORDER BY p.post_date DESC
             LIMIT %d",
            absint($author_id),
            absint($category_id),
            absint($limit)
        );

        return $wpdb->get_results($query);
    }

    /**
     * Insertion sécurisée
     */
    public static function insert_custom_data($user_id, $data_type, $data_value) {
        global $wpdb;

        $table_name = $wpdb->prefix . 'custom_data';

        // Utiliser wpdb->insert() pour l'échappement automatique
        $result = $wpdb->insert(
            $table_name,
            array(
                'user_id'    => absint($user_id),
                'data_type'  => sanitize_key($data_type),
                'data_value' => sanitize_text_field($data_value),
                'created_at' => current_time('mysql'),
            ),
            array('%d', '%s', '%s', '%s') // Types de données
        );

        if ($result === false) {
            error_log('Database insert failed: ' . $wpdb->last_error);
            return false;
        }

        return $wpdb->insert_id;
    }

    /**
     * Mise à jour sécurisée
     */
    public static function update_custom_data($id, $new_value) {
        global $wpdb;

        $table_name = $wpdb->prefix . 'custom_data';

        $result = $wpdb->update(
            $table_name,
            array(
                'data_value'  => sanitize_text_field($new_value),
                'updated_at'  => current_time('mysql'),
            ),
            array('id' => absint($id)),
            array('%s', '%s'), // Types de données pour les valeurs
            array('%d')        // Types de données pour WHERE
        );

        return $result !== false;
    }

    /**
     * Suppression sécurisée
     */
    public static function delete_custom_data($id) {
        global $wpdb;

        $table_name = $wpdb->prefix . 'custom_data';

        // Vérifier que l'utilisateur a le droit de supprimer
        if (!current_user_can('delete_posts')) {
            return false;
        }

        $result = $wpdb->delete(
            $table_name,
            array('id' => absint($id)),
            array('%d')
        );

        return $result !== false;
    }

    /**
     * Requête avec IN clause
     */
    public static function get_posts_by_ids($post_ids) {
        global $wpdb;

        // Nettoyer et valider les IDs
        $post_ids = array_map('absint', (array) $post_ids);
        $post_ids = array_filter($post_ids); // Retirer les 0

        if (empty($post_ids)) {
            return array();
        }

        // Créer les placeholders
        $placeholders = implode(',', array_fill(0, count($post_ids), '%d'));

        $query = $wpdb->prepare(
            "SELECT * FROM {$wpdb->posts}
             WHERE ID IN ($placeholders)
             AND post_status = 'publish'",
            $post_ids
        );

        return $wpdb->get_results($query);
    }

    /**
     * Transaction sécurisée
     */
    public static function transfer_post_ownership($post_id, $from_user_id, $to_user_id) {
        global $wpdb;

        // Vérifier les permissions
        if (!current_user_can('edit_others_posts')) {
            return new WP_Error('permission_denied', 'Permission refusée');
        }

        // Démarrer la transaction
        $wpdb->query('START TRANSACTION');

        try {
            // Mettre à jour le post
            $result1 = $wpdb->update(
                $wpdb->posts,
                array('post_author' => absint($to_user_id)),
                array(
                    'ID'          => absint($post_id),
                    'post_author' => absint($from_user_id),
                ),
                array('%d'),
                array('%d', '%d')
            );

            if ($result1 === false) {
                throw new Exception('Échec de mise à jour du post');
            }

            // Logger le changement
            $result2 = $wpdb->insert(
                $wpdb->prefix . 'ownership_log',
                array(
                    'post_id'      => absint($post_id),
                    'from_user_id' => absint($from_user_id),
                    'to_user_id'   => absint($to_user_id),
                    'changed_at'   => current_time('mysql'),
                ),
                array('%d', '%d', '%d', '%s')
            );

            if ($result2 === false) {
                throw new Exception('Échec du logging');
            }

            // Valider la transaction
            $wpdb->query('COMMIT');

            return true;

        } catch (Exception $e) {
            // Annuler en cas d'erreur
            $wpdb->query('ROLLBACK');

            error_log('Transaction failed: ' . $e->getMessage());

            return new WP_Error('transaction_failed', $e->getMessage());
        }
    }
}

Validation et sanitization des entrées SQL

<?php
/**
 * Fonctions de validation pour les requêtes
 */
class YAWC_SQL_Validator {

    /**
     * Valider un ORDER BY
     */
    public static function validate_orderby($orderby, $allowed_columns) {
        $orderby = sanitize_sql_orderby($orderby);

        if (empty($orderby)) {
            return 'post_date';
        }

        // Extraire la colonne
        $orderby_parts = explode(' ', $orderby);
        $column = $orderby_parts[0];

        // Vérifier contre la liste blanche
        if (!in_array($column, $allowed_columns, true)) {
            return 'post_date';
        }

        return $orderby;
    }

    /**
     * Valider une direction de tri
     */
    public static function validate_order($order) {
        $order = strtoupper($order);

        return in_array($order, array('ASC', 'DESC'), true) ? $order : 'DESC';
    }

    /**
     * Créer une requête de recherche sécurisée
     */
    public static function build_search_query($search_term, $search_fields) {
        global $wpdb;

        if (empty($search_term)) {
            return '';
        }

        $search_term = '%' . $wpdb->esc_like($search_term) . '%';
        $search_conditions = array();

        foreach ($search_fields as $field) {
            $search_conditions[] = $wpdb->prepare(
                "$field LIKE %s",
                $search_term
            );
        }

        return '(' . implode(' OR ', $search_conditions) . ')';
    }
}

/**
 * Utilisation
 */
function yawc_advanced_post_search($params) {
    global $wpdb;

    $defaults = array(
        'search'  => '',
        'orderby' => 'post_date',
        'order'   => 'DESC',
        'limit'   => 20,
        'offset'  => 0,
    );

    $params = wp_parse_args($params, $defaults);

    // Valider les paramètres
    $allowed_orderby = array('post_date', 'post_title', 'comment_count', 'ID');
    $orderby = YAWC_SQL_Validator::validate_orderby($params['orderby'], $allowed_orderby);
    $order = YAWC_SQL_Validator::validate_order($params['order']);
    $limit = absint($params['limit']);
    $offset = absint($params['offset']);

    // Construire la condition de recherche
    $search_fields = array('post_title', 'post_content', 'post_excerpt');
    $search_condition = YAWC_SQL_Validator::build_search_query($params['search'], $search_fields);

    // Construire la requête finale
    $where_clause = "post_status = 'publish' AND post_type = 'post'";

    if (!empty($search_condition)) {
        $where_clause .= " AND $search_condition";
    }

    $query = "SELECT * FROM {$wpdb->posts}
              WHERE $where_clause
              ORDER BY $orderby $order
              LIMIT $limit OFFSET $offset";

    return $wpdb->get_results($query);
}

3. Cross-Site Scripting (XSS)

Comprendre XSS

Le XSS permet à un attaquant d’injecter du JavaScript malveillant qui s’exécutera dans le navigateur des visiteurs.

<?php
/**
 * VULNÉRABLE : N'utilisez jamais ce code
 */
function yawc_display_comment_vulnerable($comment) {
    // DANGER : Le contenu du commentaire est affiché sans échappement
    echo '<div class="comment">';
    echo '<p>' . $comment->comment_content . '</p>';
    echo '</div>';
}

// Si un attaquant poste : <script>alert('XSS')</script>
// Le script s'exécutera dans le navigateur de chaque visiteur !

/**
 * SÉCURISÉ : Toujours échapper les sorties
 */
function yawc_display_comment_secure($comment) {
    echo '<div class="comment">';
    echo '<p>' . esc_html($comment->comment_content) . '</p>';
    echo '</div>';
}

Fonctions d’échappement WordPress

<?php
/**
 * Guide complet des fonctions d'échappement
 */
class YAWC_Escaping_Guide {

    /**
     * esc_html() : Pour le texte dans HTML
     */
    public static function example_esc_html() {
        $user_input = '<script>alert("XSS")</script>';

        // Convertit les caractères spéciaux en entités HTML
        echo '<p>' . esc_html($user_input) . '</p>';
        // Affiche : <script>alert("XSS")</script>
    }

    /**
     * esc_attr() : Pour les attributs HTML
     */
    public static function example_esc_attr() {
        $user_input = '" onclick="alert('XSS')';

        // Sécurise les guillemets et caractères spéciaux
        echo '<input type="text" value="' . esc_attr($user_input) . '">';
    }

    /**
     * esc_url() : Pour les URLs
     */
    public static function example_esc_url() {
        $user_url = 'javascript:alert("XSS")';

        // Valide et nettoie l'URL
        echo '<a href="' . esc_url($user_url) . '">Lien</a>';
        // Le javascript: sera retiré
    }

    /**
     * esc_js() : Pour JavaScript
     */
    public static function example_esc_js() {
        $user_input = '"; alert("XSS"); var x="';

        echo '<script>';
        echo 'var message = "' . esc_js($user_input) . '";';
        echo '</script>';
    }

    /**
     * wp_kses() : Pour HTML autorisé
     */
    public static function example_wp_kses() {
        $user_content = '<p>Texte <script>alert("XSS")</script> __gras__</p>';

        // Autoriser seulement certaines balises
        $allowed_html = array(
            'p'      => array(),
            'strong' => array(),
            'em'     => array(),
            'a'      => array(
                'href'   => array(),
                'title'  => array(),
                'target' => array(),
            ),
        );

        echo wp_kses($user_content, $allowed_html);
        // Affiche : <p>Texte alert("XSS") __gras__</p>
    }

    /**
     * wp_kses_post() : Pour contenu de post
     */
    public static function example_wp_kses_post() {
        $user_content = '<p>Texte <script>alert("XSS")</script></p>';

        // Autorise les balises autorisées dans les posts
        echo wp_kses_post($user_content);
    }

    /**
     * esc_textarea() : Pour textarea
     */
    public static function example_esc_textarea() {
        $user_input = '</textarea><script>alert("XSS")</script><textarea>';

        echo '<textarea>';
        echo esc_textarea($user_input);
        echo '</textarea>';
    }

    /**
     * sanitize_text_field() : Nettoie le texte
     */
    public static function example_sanitize() {
        $user_input = "<script>alert('XSS')</script>nNouvelle ligne";

        // Retire les balises et normalise
        $clean = sanitize_text_field($user_input);
        // Résultat : "alert('XSS')Nouvelle ligne"

        return $clean;
    }
}

/**
 * Exemple complet : Formulaire de commentaire sécurisé
 */
function yawc_render_secure_comment_form($post_id) {
    $current_user = wp_get_current_user();

    ?>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="comment-form">
        <?php wp_nonce_field('submit_comment*' . $post_id, 'comment_nonce'); ?>
        <input type="hidden" name="action" value="submit_comment">
        <input type="hidden" name="post_id" value="<?php echo absint($post_id); ?>">

        <div class="form-field">
            <label for="comment_author">
                Nom <span class="required">*</span>
            </label>
            <input
                type="text"
                name="comment_author"
                id="comment_author"
                value="<?php echo esc_attr($current_user->display_name); ?>"
                required
            >
        </div>

        <div class="form-field">
            <label for="comment_email">
                Email <span class="required">*</span>
            </label>
            <input
                type="email"
                name="comment_email"
                id="comment_email"
                value="<?php echo esc_attr($current_user->user_email); ?>"
                required
            >
        </div>

        <div class="form-field">
            <label for="comment_url">Site web</label>
            <input
                type="url"
                name="comment_url"
                id="comment_url"
                value="<?php echo esc_url($current_user->user_url); ?>"
            >
        </div>

        <div class="form-field">
            <label for="comment_content">
                Commentaire <span class="required">*</span>
            </label>
            <textarea
                name="comment_content"
                id="comment_content"
                rows="5"
                required
            ><?php echo isset($*POST['comment_content']) ? esc_textarea($*POST['comment_content']) : ''; ?></textarea>
        </div>

        <button type="submit" class="submit-button">
            Publier le commentaire
        </button>
    </form>
    <?php
}

/**
 * Traitement sécurisé du formulaire
 */
add_action('admin_post_submit_comment', 'yawc_handle_comment_submission');
add_action('admin_post_nopriv_submit_comment', 'yawc_handle_comment_submission');

function yawc_handle_comment_submission() {
    // 1. Vérifier le nonce
    $post_id = isset($*POST['post_id']) ? absint($*POST['post_id']) : 0;

    if (!isset($*POST['comment_nonce']) ||
        !wp_verify_nonce($*POST['comment_nonce'], 'submit_comment*' . $post_id)) {
        wp_die('Erreur de sécurité : nonce invalide');
    }

    // 2. Valider les données
    $errors = array();

    $comment_author = isset($*POST['comment_author'])
        ? sanitize_text_field($*POST['comment_author'])
        : '';

    if (empty($comment_author)) {
        $errors[] = 'Le nom est requis';
    }

    $comment_email = isset($*POST['comment_email'])
        ? sanitize_email($*POST['comment_email'])
        : '';

    if (empty($comment_email) || !is_email($comment_email)) {
        $errors[] = 'Une adresse email valide est requise';
    }

    $comment_url = isset($*POST['comment_url'])
        ? esc_url_raw($*POST['comment_url'])
        : '';

    $comment_content = isset($*POST['comment_content'])
        ? sanitize_textarea_field($*POST['comment_content'])
        : '';

    if (empty($comment_content)) {
        $errors[] = 'Le commentaire ne peut pas être vide';
    }

    // 3. Vérifier les erreurs
    if (!empty($errors)) {
        wp_die(implode('
', $errors)); } // 4. Insérer le commentaire $comment_data = array( 'comment_post_ID' => $post_id, 'comment_author' => $comment_author, 'comment_author_email' => $comment_email, 'comment_author_url' => $comment_url, 'comment_content' => $comment_content, 'comment_type' => 'comment', 'comment_parent' => 0, 'user_id' => get_current_user_id(), 'comment_author_IP' => YAWC_Security_Framework::get_client_ip(), 'comment_agent' => isset($*SERVER['HTTP_USER_AGENT']) ? substr($*SERVER['HTTP_USER_AGENT'], 0, 254) : '', 'comment_date' => current_time('mysql'), 'comment_approved' => 1, // Ou 0 pour modération ); $comment_id = wp_insert_comment($comment_data); if ($comment_id) { // Redirection vers l'article avec ancre vers le commentaire wp_redirect(get_permalink($post_id) . '#comment-' . $comment_id); exit; } else { wp_die('Erreur lors de l'ajout du commentaire'); } }

Protection contre XSS dans JSON et AJAX

<?php
/**
 * Sécuriser les réponses AJAX
 */
add_action('wp_ajax_get_user_data', 'yawc_ajax_get_user_data');

function yawc_ajax_get_user_data() {
    // Vérifier le nonce
    check_ajax_referer('get-user-data', 'nonce');

    $user_id = isset($*POST['user_id']) ? absint($*POST['user_id']) : 0;

    if (!$user_id) {
        wp_send_json_error(array('message' => 'ID utilisateur invalide'));
    }

    $user = get_userdata($user_id);

    if (!$user) {
        wp_send_json_error(array('message' => 'Utilisateur non trouvé'));
    }

    // Nettoyer toutes les données avant de les envoyer
    $user_data = array(
        'id'           => absint($user->ID),
        'display_name' => sanitize_text_field($user->display_name),
        'email'        => sanitize_email($user->user_email),
        'bio'          => wp_kses_post($user->description),
        'avatar_url'   => esc_url(get_avatar_url($user->ID)),
    );

    // wp_send_json_success encode automatiquement en JSON
    wp_send_json_success($user_data);
}

/**
 * JavaScript côté client
 */
?>
<script>
jQuery(document).ready(function($) {
    $('#load-user').on('click', function() {
        $.ajax({
            url: ajaxurl,
            type: 'POST',
            data: {
                action: 'get_user_data',
                user_id: $('#user-id').val(),
                nonce: '<?php echo wp_create_nonce('get-user-data'); ?>'
            },
            success: function(response) {
                if (response.success) {
                    var user = response.data;

                    // Échapper les données même en JS
                    var html = '<div class="user-card">';
                    html += '### ' + $('<div>').text(user.display_name).html() + '';
                    html += '<p>' + $('<div>').text(user.email).html() + '</p>';
                    html += '<div>' + user.bio + '</div>'; // Déjà nettoyé par wp_kses_post
                    html += '<img src="' + user.avatar_url + '" alt="Avatar">';
                    html += '</div>';

                    $('#user-container').html(html);
                }
            }
        });
    });
});
</script>
<?php

4. Cross-Site Request Forgery (CSRF)

Comprendre CSRF

Le CSRF exploite la confiance qu’un site a envers un utilisateur authentifié en forçant l’exécution d’actions non autorisées.

<?php
/**
 * VULNÉRABLE : Action sans vérification CSRF
 */
function yawc_delete_post_vulnerable() {
    $post_id = $*GET['post_id'];

    // DANGER : N'importe qui peut créer un lien malveillant
    // <img src="https://votresite.com/wp-admin/admin.php?action=delete&post_id=123">
    wp_delete_post($post_id, true);

    wp_redirect(admin_url('edit.php'));
    exit;
}

/**
 * SÉCURISÉ : Utilisation de nonces
 */
function yawc_delete_post_secure() {
    // Vérifier le nonce
    if (!isset($*GET['*wpnonce']) ||
        !wp_verify_nonce($*GET['*wpnonce'], 'delete-post*' . $*GET['post_id'])) {
        wp_die('Action non autorisée');
    }

    // Vérifier les permissions
    if (!current_user_can('delete_post', $*GET['post_id'])) {
        wp_die('Permissions insuffisantes');
    }

    $post_id = absint($*GET['post_id']);

    wp_delete_post($post_id, true);

    wp_redirect(admin_url('edit.php'));
    exit;
}

/**
 * Génération du lien sécurisé
 */
function yawc_get_delete_post_link($post_id) {
    $url = admin_url('admin.php');

    $url = add_query_arg(array(
        'action'  => 'delete_post',
        'post_id' => $post_id,
    ), $url);

    // Ajouter le nonce
    $url = wp_nonce_url($url, 'delete-post*' . $post_id);

    return $url;
}

// Affichage dans un template
echo '<a href="' . esc_url(yawc_get_delete_post_link(123)) . '">Supprimer</a>';

Nonces pour formulaires

<?php
/**
 * Formulaire protégé par nonce
 */
function yawc_render_settings_form() {
    ?>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <?php
        // Générer un champ nonce caché
        wp_nonce_field('yawc_save_settings', 'yawc_settings_nonce');
        ?>

        <input type="hidden" name="action" value="yawc_save_settings">

        <label>
            Option 1:
            <input type="text" name="option_1" value="<?php echo esc_attr(get_option('yawc_option_1')); ?>">
        </label>

        <button type="submit">Enregistrer</button>
    </form>
    <?php
}

/**
 * Traitement sécurisé
 */
add_action('admin_post_yawc_save_settings', 'yawc_save_settings');

function yawc_save_settings() {
    // Vérifier le nonce
    if (!isset($*POST['yawc_settings_nonce']) ||
        !wp_verify_nonce($*POST['yawc_settings_nonce'], 'yawc_save_settings')) {
        wp_die('Nonce invalide');
    }

    // Vérifier les permissions
    if (!current_user_can('manage_options')) {
        wp_die('Permissions insuffisantes');
    }

    // Traiter les données
    if (isset($*POST['option_1'])) {
        update_option('yawc_option_1', sanitize_text_field($*POST['option_1']));
    }

    // Redirection
    wp_redirect(add_query_arg('settings-updated', 'true', wp_get_referer()));
    exit;
}

Nonces pour AJAX

<?php
/**
 * Configuration du nonce pour AJAX
 */
function yawc_enqueue_ajax_scripts() {
    wp_enqueue_script('yawc-ajax', get_template_directory_uri() . '/js/ajax.js', array('jquery'), '1.0', true);

    wp_localize_script('yawc-ajax', 'yawcAjax', array(
        'ajaxurl' => admin_url('admin-ajax.php'),
        'nonce'   => wp_create_nonce('yawc-ajax-nonce'),
    ));
}
add_action('wp_enqueue_scripts', 'yawc_enqueue_ajax_scripts');

/**
 * Handler AJAX sécurisé
 */
add_action('wp_ajax_yawc_update_profile', 'yawc_ajax_update_profile');

function yawc_ajax_update_profile() {
    // Vérifier le nonce
    check_ajax_referer('yawc-ajax-nonce', 'nonce');

    // Vérifier l'authentification
    if (!is_user_logged_in()) {
        wp_send_json_error(array('message' => 'Vous devez être connecté'));
    }

    $user_id = get_current_user_id();

    // Valider et nettoyer les données
    $display_name = isset($*POST['display_name'])
        ? sanitize_text_field($*POST['display_name'])
        : '';

    if (empty($display_name)) {
        wp_send_json_error(array('message' => 'Le nom est requis'));
    }

    // Mettre à jour
    $result = wp_update_user(array(
        'ID'           => $user_id,
        'display_name' => $display_name,
    ));

    if (is_wp_error($result)) {
        wp_send_json_error(array('message' => $result->get_error_message()));
    }

    wp_send_json_success(array(
        'message' => 'Profil mis à jour',
        'user'    => array(
            'display_name' => $display_name,
        ),
    ));
}

JavaScript correspondant:

// ajax.js
jQuery(document).ready(function($) {
    $('#update-profile-form').on('submit', function(e) {
        e.preventDefault();

        $.ajax({
            url: yawcAjax.ajaxurl,
            type: 'POST',
            data: {
                action: 'yawc_update_profile',
                nonce: yawcAjax.nonce,
                display_name: $('#display_name').val()
            },
            success: function(response) {
                if (response.success) {
                    alert(response.data.message);
                } else {
                    alert('Erreur : ' + response.data.message);
                }
            },
            error: function() {
                alert('Erreur de connexion');
            }
        });
    });
});

Gestion avancée des nonces

<?php
/**
 * Classe de gestion avancée des nonces
 */
class YAWC_Nonce_Manager {

    /**
     * Durée de vie des nonces (en secondes)
     */
    const NONCE_LIFETIME = 12 * HOUR_IN_SECONDS;

    /**
     * Créer un nonce avec métadonnées
     */
    public static function create($action, $context = array()) {
        $nonce = wp_create_nonce($action);

        // Stocker les métadonnées du nonce
        $nonce_data = array(
            'action'     => $action,
            'context'    => $context,
            'created_at' => current_time('timestamp'),
            'user_id'    => get_current_user_id(),
            'ip'         => YAWC_Security_Framework::get_client_ip(),
        );

        set_transient('yawc_nonce*' . $nonce, $nonce_data, self::NONCE_LIFETIME);

        return $nonce;
    }

    /**
     * Vérifier un nonce avec validation stricte
     */
    public static function verify($nonce, $action, $strict = false) {
        // Vérification WordPress standard
        $verify = wp_verify_nonce($nonce, $action);

        if (!$verify) {
            self::log_failed_nonce($nonce, $action);
            return false;
        }

        if (!$strict) {
            return true;
        }

        // Vérifications supplémentaires en mode strict
        $nonce_data = get_transient('yawc_nonce*' . $nonce);

        if (!$nonce_data) {
            return true; // Les métadonnées sont optionnelles
        }

        // Vérifier que l'IP n'a pas changé
        if ($nonce_data['ip'] !== YAWC_Security_Framework::get_client_ip()) {
            self::log_failed_nonce($nonce, $action, 'IP mismatch');
            return false;
        }

        // Vérifier que l'utilisateur n'a pas changé
        if ($nonce_data['user_id'] != get_current_user_id()) {
            self::log_failed_nonce($nonce, $action, 'User ID mismatch');
            return false;
        }

        return true;
    }

    /**
     * Créer un nonce à usage unique
     */
    public static function create_single_use($action) {
        $nonce = self::create($action, array('single_use' => true));

        return $nonce;
    }

    /**
     * Vérifier et consommer un nonce à usage unique
     */
    public static function verify_single_use($nonce, $action) {
        $nonce_data = get_transient('yawc_nonce*' . $nonce);

        if (!$nonce_data || empty($nonce_data['context']['single_use'])) {
            return self::verify($nonce, $action);
        }

        // Vérifier
        $valid = self::verify($nonce, $action);

        if ($valid) {
            // Consommer le nonce (le supprimer)
            delete_transient('yawc_nonce*' . $nonce);
        }

        return $valid;
    }

    /**
     * Logger les échecs de vérification
     */
    private static function log_failed_nonce($nonce, $action, $reason = 'Invalid nonce') {
        error_log(sprintf(
            '[SECURITY] Failed nonce verification - Action: %s, Reason: %s, IP: %s, User: %d',
            $action,
            $reason,
            YAWC_Security_Framework::get_client_ip(),
            get_current_user_id()
        ));

        // Incrémenter le compteur d'échecs
        $ip = YAWC_Security_Framework::get_client_ip();
        $key = 'nonce_failures*' . $ip;
        $failures = get_transient($key) ?: 0;
        $failures++;

        set_transient($key, $failures, HOUR_IN_SECONDS);

        // Bloquer après 10 échecs
        if ($failures >= 10) {
            YAWC_Security_Framework::block_ip($ip, 'Too many failed nonce verifications');
        }
    }

    /**
     * Nettoyer les vieux nonces
     */
    public static function cleanup_expired() {
        global $wpdb;

        $prefix = 'yawc_nonce*';

        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->options}
                 WHERE option_name LIKE %s
                 AND option_name LIKE '%%transient%%'",
                '%' . $wpdb->esc_like($prefix) . '%'
            )
        );
    }
}

// Nettoyer les vieux nonces quotidiennement
if (!wp_next_scheduled('yawc_cleanup_nonces')) {
    wp_schedule_event(time(), 'daily', 'yawc_cleanup_nonces');
}
add_action('yawc_cleanup_nonces', array('YAWC_Nonce_Manager', 'cleanup_expired'));

Conclusion

La sécurité WordPress est un processus continu qui nécessite vigilance et mise à jour constante des pratiques. En suivant les principes présentés dans ce guide, vous construisez une défense solide contre les attaques courantes.

Points clés à retenir

  • Défense en profondeur – Multiplier les couches de sécurité
  • Validation des entrées – Ne jamais faire confiance aux données utilisateur
  • Échappement des sorties – Toujours échapper avant l’affichage
  • Requêtes préparées – Utiliser $wpdb->prepare() systématiquement
  • Nonces partout – Protéger toutes les actions avec des nonces
  • Principe du moindre privilège – Limiter les permissions au strict nécessaire
  • Logging et monitoring – Surveiller les activités suspectes
  • Mise à jour régulière – WordPress, plugins et thèmes à jour
  • Liste de vérification sécurité

  • [ ] Toutes les requêtes SQL utilisent prepare()
  • [ ] Toutes les sorties sont échappées avec les bonnes fonctions
  • [ ] Tous les formulaires incluent des nonces
  • [ ] Toutes les actions AJAX vérifient les nonces
  • [ ] Les uploads de fichiers sont validés et sécurisés
  • [ ] Les permissions sont vérifiées sur les actions sensibles
  • [ ] Les en-têtes de sécurité sont configurés
  • [ ] Les tentatives de connexion sont limitées
  • [ ] Les erreurs ne révèlent pas d’informations sensibles
  • [ ] Le logging de sécurité est en place
  • La sécurité n’est jamais terminée – restez informé des nouvelles vulnérabilités et mettez à jour vos pratiques régulièrement.

    À propos de l'auteur

    Pascal

    Pascal — SRE & Observability Engineer. J'applique les pratiques SRE (SLI/SLO, alerting, observabilité) à mon homelab Proxmox, avec les mêmes exigences qu'en production.

    Une remarque, un retour ?

    Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisis celui qui te convient.

    Laisser un commentaire