Avance 1 min de lecture · 218 mots

Sécurité WordPress Avancée : Prévention des Vulnérabilités OWASP

Estimated reading time: 1 minute

Introduction : L’Impératif de Sécurité en 2025

WordPress alimente plus de 43% du web, ce qui en fait une cible privilégiée pour les cyberattaques. En 2025, l’OWASP Top 10 continue d’identifier les vulnérabilités critiques qui affectent les applications web, et WordPress n’est pas épargné. Cet article présente une approche systématique de sécurisation avancée basée sur les standards OWASP, avec du code production-ready et des stratégies de défense en profondeur.

L’approche traditionnelle de « sécurité par plugin » est insuffisante pour les applications WordPress d’entreprise. Une stratégie multicouche, intégrant la sécurité dès la conception (Security by Design), est essentielle pour protéger les données sensibles et maintenir la confiance des utilisateurs.

OWASP Top 10 2025 : Application à WordPress

A01:2021 – Broken Access Control

Le contrôle d’accès défaillant est la vulnérabilité numéro 1 selon l’OWASP. Dans WordPress, cela se manifeste par des vérifications insuffisantes de capabilities et de nonces.

Vulnérabilité classique :

action('wpajaxdeleteuserdata', function() {
    $userid = $POST['userid'];
    deleteusermeta($userid, 'sensitivedata');
    wpsendjsonsuccess();
});

Implémentation sécurisée :


  Gestionnaire de contrôle d'accès robuste
 /
class AccessControlManager {
    private const NONCEACTION = 'secureajaxaction';
    private const NONCEFIELD = 'wpnonce';

    /
      Vérifie les permissions et le nonce
     
      @throws SecurityException
     /
    public function verifyAccess(
        string $capability,
        ?string $nonce = null,
        ?string $nonceAction = null
    ): void {
        // Vérification de l'authentification
        if (!isuserloggedin()) {
            throw new SecurityException(
                'User must be authenticated',
                SecurityException::CODEAUTHENTICATIONREQUIRED
            );
        }

        // Vérification des capabilities
        if (!currentusercan($capability)) {
            $this->logUnauthorizedAccess($capability);
            throw new SecurityException(
                "Insufficient permissions: {$capability} required",
                SecurityException::CODEINSUFFICIENTPERMISSIONS
            );
        }

        // Vérification du nonce si fourni
        if ($nonce !== null) {
            $nonceAction = $nonceAction ?? self::NONCEACTION;

            if (!wpverifynonce($nonce, $nonceAction)) {
                $this->logInvalidNonce($nonceAction);
                throw new SecurityException(
                    'Invalid security token',
                    SecurityException::CODEINVALIDNONCE
                );
            }
        }
    }

    /
      Vérifie l'accès à une ressource spécifique
     
      @throws SecurityException
     /
    public function verifyResourceAccess(
        int $postId,
        string $capability = 'editpost'
    ): void {
        if (!currentusercan($capability, $postId)) {
            $this->logUnauthorizedResourceAccess($postId, $capability);
            throw new SecurityException(
                "Unauthorized access to resource {$postId}",
                SecurityException::CODERESOURCEACCESSDENIED
            );
        }
    }

    /
      Crée un nonce sécurisé
     /
    public function createNonce(string $action = null): string {
        $action = $action ?? self::NONCEACTION;
        return wpcreatenonce($action);
    }

    /
      Génère un URL sécurisé avec nonce
     /
    public function createSecureUrl(string $url, string $action = null): string {
        $action = $action ?? self::NONCEACTION;
        return wpnonceurl($url, $action, self::NONCEFIELD);
    }

    private function logUnauthorizedAccess(string $capability): void {
        $user = wpgetcurrentuser();
        errorlog(sprintf(
            '[SECURITY] Unauthorized access attempt - User: %d (%s), Required capability: %s, IP: %s',
            $user->ID,
            $user->userlogin,
            $capability,
            $this->getClientIp()
        ));
    }

    private function logInvalidNonce(string $action): void {
        errorlog(sprintf(
            '[SECURITY] Invalid nonce - Action: %s, User: %d, IP: %s',
            $action,
            getcurrentuserid(),
            $this->getClientIp()
        ));
    }

    private function logUnauthorizedResourceAccess(int $resourceId, string $capability): void {
        errorlog(sprintf(
            '[SECURITY] Unauthorized resource access - Resource: %d, Capability: %s, User: %d, IP: %s',
            $resourceId,
            $capability,
            getcurrentuserid(),
            $this->getClientIp()
        ));
    }

    private function getClientIp(): string {
        $headers = ['HTTPCFCONNECTINGIP', 'HTTPXFORWARDEDFOR', 'REMOTEADDR'];

        foreach ($headers as $header) {
            if (!empty($SERVER[$header])) {
                $ip = $SERVER[$header];
                // Prendre la première IP en cas de liste
                if (strpos($ip, ',') !== false) {
                    $ip = explode(',', $ip)[0];
                }
                return trim($ip);
            }
        }

        return 'unknown';
    }
}

/
  Exception de sécurité
 /
class SecurityException extends RuntimeException {
    public const CODEAUTHENTICATIONREQUIRED = 1001;
    public const CODEINSUFFICIENTPERMISSIONS = 1002;
    public const CODEINVALIDNONCE = 1003;
    public const CODERESOURCEACCESSDENIED = 1004;
}

/
  Utilisation dans un endpoint AJAX
 /
class SecureAjaxHandler {
    private AccessControlManager $accessControl;

    public function construct(AccessControlManager $accessControl) {
        $this->accessControl = $accessControl;
    }

    public function register(): void {
        addaction('wpajaxdeleteuserdata', [$this, 'deleteUserData']);
    }

    public function deleteUserData(): void {
        try {
            // Vérification multi-niveaux
            $this->accessControl->verifyAccess(
                'manageoptions',
                $POST['wpnonce'] ?? null,
                'deleteuserdata'
            );

            $userId = filterinput(INPUTPOST, 'userid', FILTERVALIDATEINT);

            if (!$userId) {
                throw new InvalidArgumentException('Invalid user ID');
            }

            // Vérification supplémentaire : empêcher la suppression de son propre compte
            if ($userId === getcurrentuserid()) {
                throw new SecurityException(
                    'Cannot delete own account data via this endpoint',
                    SecurityException::CODERESOURCEACCESSDENIED
                );
            }

            deleteusermeta($userId, 'sensitivedata');

            wpsendjsonsuccess([
                'message' => 'User data deleted successfully',
            ]);

        } catch (SecurityException $e) {
            wpsendjsonerror([
                'message' => $e->getMessage(),
                'code' => $e->getCode(),
            ], 403);

        } catch (Exception $e) {
            errorlog('[ERROR] ' . $e->getMessage());
            wpsendjsonerror([
                'message' => 'An error occurred',
            ], 500);
        }
    }
}

A02:2021 – Cryptographic Failures

Les défaillances cryptographiques exposent des données sensibles. WordPress nécessite une gestion rigoureuse du chiffrement.


  Service de chiffrement sécurisé
 /
class EncryptionService {
    private const CIPHERMETHOD = 'aes-256-gcm';
    private string $key;

    public function construct() {
        // Récupération de la clé depuis une variable d'environnement
        $key = getenv('APPENCRYPTIONKEY');

        if (!$key) {
            throw new RuntimeException(
                'Encryption key not configured. Set APPENCRYPTIONKEY environment variable.'
            );
        }

        // La clé doit faire 32 bytes pour AES-256
        $this->key = hash('sha256', $key, true);
    }

    /
      Chiffre des données sensibles
     
      @param string $data Données à chiffrer
      @return string Données chiffrées encodées en base64
      @throws EncryptionException
     /
    public function encrypt(string $data): string {
        try {
            $ivLength = opensslcipherivlength(self::CIPHERMETHOD);
            $iv = opensslrandompseudobytes($ivLength);
            $tag = '';

            $encrypted = opensslencrypt(
                $data,
                self::CIPHERMETHOD,
                $this->key,
                OPENSSLRAWDATA,
                $iv,
                $tag,
                '',
                16 // Tag length for GCM
            );

            if ($encrypted === false) {
                throw new EncryptionException('Encryption failed');
            }

            // Concaténer IV + tag + données chiffrées
            $result = $iv . $tag . $encrypted;

            return base64encode($result);

        } catch (Exception $e) {
            throw new EncryptionException(
                'Encryption error: ' . $e->getMessage(),
                0,
                $e
            );
        }
    }

    /
      Déchiffre des données
     
      @param string $encryptedData Données chiffrées (base64)
      @return string Données déchiffrées
      @throws EncryptionException
     /
    public function decrypt(string $encryptedData): string {
        try {
            $data = base64decode($encryptedData, true);

            if ($data === false) {
                throw new EncryptionException('Invalid encrypted data format');
            }

            $ivLength = opensslcipherivlength(self::CIPHERMETHOD);
            $tagLength = 16;

            $iv = substr($data, 0, $ivLength);
            $tag = substr($data, $ivLength, $tagLength);
            $encrypted = substr($data, $ivLength + $tagLength);

            $decrypted = openssldecrypt(
                $encrypted,
                self::CIPHERMETHOD,
                $this->key,
                OPENSSLRAWDATA,
                $iv,
                $tag
            );

            if ($decrypted === false) {
                throw new EncryptionException('Decryption failed - data may be corrupted');
            }

            return $decrypted;

        } catch (Exception $e) {
            throw new EncryptionException(
                'Decryption error: ' . $e->getMessage(),
                0,
                $e
            );
        }
    }

    /
      Hash sécurisé pour les données sensibles (unidirectionnel)
     /
    public function hash(string $data, ?string $salt = null): string {
        $salt = $salt ?? bin2hex(randombytes(16));
        return hashpbkdf2('sha256', $data, $salt, 100000, 64) . ':' . $salt;
    }

    /
      Vérifie un hash
     /
    public function verifyHash(string $data, string $hash): bool {
        $parts = explode(':', $hash);

        if (count($parts) !== 2) {
            return false;
        }

        [$storedHash, $salt] = $parts;
        $computedHash = hashpbkdf2('sha256', $data, $salt, 100000, 64);

        return hashequals($storedHash, $computedHash);
    }
}

/
  Stockage sécurisé de données sensibles
 /
class SecureMetaStorage {
    private EncryptionService $encryption;

    public function construct(EncryptionService $encryption) {
        $this->encryption = $encryption;
    }

    /
      Stocke une métadonnée sensible chiffrée
     /
    public function updateSecureMeta(
        int $objectId,
        string $metaKey,
        string $value,
        string $objectType = 'post'
    ): bool {
        $encrypted = $this->encryption->encrypt($value);

        switch ($objectType) {
            case 'user':
                return updateusermeta($objectId, $metaKey, $encrypted);
            case 'post':
                return updatepostmeta($objectId, $metaKey, $encrypted);
            default:
                return updatemetadata($objectType, $objectId, $metaKey, $encrypted);
        }
    }

    /
      Récupère une métadonnée sensible déchiffrée
     /
    public function getSecureMeta(
        int $objectId,
        string $metaKey,
        string $objectType = 'post'
    ): ?string {
        $encrypted = match ($objectType) {
            'user' => getusermeta($objectId, $metaKey, true),
            'post' => getpostmeta($objectId, $metaKey, true),
            default => getmetadata($objectType, $objectId, $metaKey, true),
        };

        if (empty($encrypted)) {
            return null;
        }

        try {
            return $this->encryption->decrypt($encrypted);
        } catch (EncryptionException $e) {
            errorlog('[SECURITY] Decryption failed for meta: ' . $metaKey);
            return null;
        }
    }
}

class EncryptionException extends RuntimeException {}

A03:2021 – Injection (SQL, XSS, Command)

L’injection reste une menace majeure. WordPress fournit des outils, mais leur utilisation correcte est cruciale.

Protection contre l’injection SQL :


  Query Builder sécurisé avec prepared statements
 /
class SecureDatabaseQuery {
    private wpdb $wpdb;

    public function construct(wpdb $wpdb) {
        $this->wpdb = $wpdb;
    }

    /
      Exécute une requête SELECT sécurisée
     
      @param string $table Nom de la table (sera échappé)
      @param array $columns Colonnes à sélectionner (sera validé)
      @param array $where Conditions WHERE ['column' => 'value']
      @param array $options Options (orderby, limit, offset)
      @return array
     /
    public function select(
        string $table,
        array $columns = [''],
        array $where = [],
        array $options = []
    ): array {
        // Validation du nom de table
        $table = $this->sanitizeTableName($table);

        // Validation des colonnes
        $columns = $this->sanitizeColumns($columns);
        $columnsList = implode(', ', $columns);

        // Construction de la requête de base
        $query = "SELECT {$columnsList} FROM {$table}";

        // Ajout des conditions WHERE
        $whereClauses = [];
        $values = [];

        foreach ($where as $column => $value) {
            $column = $this->sanitizeColumnName($column);

            if (isarray($value)) {
                // Opérateur IN
                $placeholders = implode(',', arrayfill(0, count($value), '%s'));
                $whereClauses[] = "{$column} IN ({$placeholders})";
                $values = arraymerge($values, $value);
            } elseif (isnull($value)) {
                $whereClauses[] = "{$column} IS NULL";
            } else {
                $whereClauses[] = "{$column} = %s";
                $values[] = $value;
            }
        }

        if (!empty($whereClauses)) {
            $query .= ' WHERE ' . implode(' AND ', $whereClauses);
        }

        // Ajout des options ORDER BY
        if (!empty($options['orderby'])) {
            $orderby = $this->sanitizeColumnName($options['orderby']);
            $order = strtoupper($options['order'] ?? 'ASC');
            $order = inarray($order, ['ASC', 'DESC']) ? $order : 'ASC';
            $query .= " ORDER BY {$orderby} {$order}";
        }

        // Ajout de LIMIT/OFFSET
        if (isset($options['limit'])) {
            $limit = (int) $options['limit'];
            $query .= " LIMIT {$limit}";

            if (isset($options['offset'])) {
                $offset = (int) $options['offset'];
                $query .= " OFFSET {$offset}";
            }
        }

        // Préparation et exécution
        if (!empty($values)) {
            $query = $this->wpdb->prepare($query, ...$values);
        }

        return $this->wpdb->getresults($query, ARRAYA);
    }

    /
      Exécute une insertion sécurisée
     /
    public function insert(string $table, array $data): int {
        $table = $this->sanitizeTableName($table);

        $result = $this->wpdb->insert(
            $table,
            $data,
            $this->inferDataTypes($data)
        );

        if ($result === false) {
            throw new DatabaseException(
                "Insert failed: {$this->wpdb->lasterror}"
            );
        }

        return $this->wpdb->insertid;
    }

    /
      Exécute une mise à jour sécurisée
     /
    public function update(string $table, array $data, array $where): int {
        $table = $this->sanitizeTableName($table);

        $result = $this->wpdb->update(
            $table,
            $data,
            $where,
            $this->inferDataTypes($data),
            $this->inferDataTypes($where)
        );

        if ($result === false) {
            throw new DatabaseException(
                "Update failed: {$this->wpdb->lasterror}"
            );
        }

        return $result;
    }

    /
      Exécute une suppression sécurisée
     /
    public function delete(string $table, array $where): int {
        $table = $this->sanitizeTableName($table);

        $result = $this->wpdb->delete(
            $table,
            $where,
            $this->inferDataTypes($where)
        );

        if ($result === false) {
            throw new DatabaseException(
                "Delete failed: {$this->wpdb->lasterror}"
            );
        }

        return $result;
    }

    private function sanitizeTableName(string $table): string {
        // Permettre uniquement les caractères alphanumériques et underscores
        if (!pregmatch('/^[a-zA-Z0-9]+$/', $table)) {
            throw new DatabaseException("Invalid table name: {$table}");
        }

        // Ajouter le préfixe WordPress si nécessaire
        if (strpos($table, $this->wpdb->prefix) !== 0) {
            $table = $this->wpdb->prefix . $table;
        }

        return $table;
    }

    private function sanitizeColumnName(string $column): string {
        // Permettre les caractères alphanumériques, underscores et points (pour les alias)
        if (!pregmatch('/^[a-zA-Z0-9.]+$/', $column)) {
            throw new DatabaseException("Invalid column name: {$column}");
        }

        return $column;
    }

    private function sanitizeColumns(array $columns): array {
        if (inarray('', $columns, true)) {
            return [''];
        }

        return arraymap(
            fn($col) => $this->sanitizeColumnName($col),
            $columns
        );
    }

    private function inferDataTypes(array $data): array {
        $types = [];

        foreach ($data as $value) {
            if (isint($value)) {
                $types[] = '%d';
            } elseif (isfloat($value)) {
                $types[] = '%f';
            } else {
                $types[] = '%s';
            }
        }

        return $types;
    }
}

class DatabaseException extends RuntimeException {}

Protection contre XSS (Cross-Site Scripting) :


  Service de sanitization et d'échappement contextuel
 /
class OutputSanitizer {
    /
      Échappe pour contexte HTML
     /
    public function escapeHtml(string $text): string {
        return eschtml($text);
    }

    /
      Échappe pour attribut HTML
     /
    public function escapeAttribute(string $text): string {
        return escattr($text);
    }

    /
      Échappe pour URL
     /
    public function escapeUrl(string $url): string {
        return escurl($url);
    }

    /
      Échappe pour JavaScript
     /
    public function escapeJs(string $text): string {
        return escjs($text);
    }

    /
      Autorise du HTML sécurisé (avec whitelist)
     /
    public function allowSafeHtml(string $html, string $context = 'post'): string {
        return wpkses($html, $this->getAllowedHtmlTags($context));
    }

    /
      Sanitize du HTML soumis par l'utilisateur
     /
    public function sanitizeUserHtml(string $html): string {
        // Utiliser DOMDocument pour parser et nettoyer
        $dom = new DOMDocument();
        libxmluseinternalerrors(true);

        $dom->loadHTML(
            mbconvertencoding($html, 'HTML-ENTITIES', 'UTF-8'),
            LIBXMLHTMLNOIMPLIED | LIBXMLHTMLNODEFDTD
        );

        libxmlclearerrors();

        // Supprimer tous les scripts
        $scripts = $dom->getElementsByTagName('script');
        while ($scripts->length > 0) {
            $scripts->item(0)->parentNode->removeChild($scripts->item(0));
        }

        // Supprimer les événements inline
        $xpath = new DOMXPath($dom);
        $nodes = $xpath->query('//[@[starts-with(name(), "on")]]');

        foreach ($nodes as $node) {
            $attributes = [];
            foreach ($node->attributes as $attr) {
                if (strpos($attr->name, 'on') === 0) {
                    $attributes[] = $attr->name;
                }
            }

            foreach ($attributes as $attr) {
                $node->removeAttribute($attr);
            }
        }

        // Supprimer les javascript: dans les URLs
        $links = $dom->getElementsByTagName('a');
        foreach ($links as $link) {
            $href = $link->getAttribute('href');
            if (stripos($href, 'javascript:') === 0) {
                $link->setAttribute('href', '#');
            }
        }

        return $dom->saveHTML();
    }

    /
      Sanitize une valeur selon son type
     /
    public function sanitize(mixed $value, string $type): mixed {
        return match ($type) {
            'email' => sanitizeemail($value),
            'text' => sanitizetextfield($value),
            'textarea' => sanitizetextareafield($value),
            'url' => escurlraw($value),
            'int' => (int) $value,
            'float' => (float) $value,
            'bool' => (bool) $value,
            'slug' => sanitizetitle($value),
            'key' => sanitizekey($value),
            'filename' => sanitizefilename($value),
            'html' => $this->sanitizeUserHtml($value),
            default => sanitizetextfield($value),
        };
    }

    private function getAllowedHtmlTags(string $context): array {
        $basic = [
            'a' => [
                'href' => true,
                'title' => true,
                'target' => true,
                'rel' => true,
            ],
            'br' => [],
            'em' => [],
            'strong' => [],
            'p' => ['class' => true],
            'span' => ['class' => true],
            'ul' => ['class' => true],
            'ol' => ['class' => true],
            'li' => ['class' => true],
        ];

        $post = arraymerge($basic, [
            'h1' => ['class' => true, 'id' => true],
            'h2' => ['class' => true, 'id' => true],
            'h3' => ['class' => true, 'id' => true],
            'h4' => ['class' => true, 'id' => true],
            'blockquote' => ['class' => true],
            'code' => ['class' => true],
            'pre' => ['class' => true],
            'img' => [
                'src' => true,
                'alt' => true,
                'width' => true,
                'height' => true,
                'class' => true,
            ],
        ]);

        return match ($context) {
            'post' => $post,
            'comment' => $basic,
            default => $basic,
        };
    }
}

/
  Template renderer sécurisé
 /
class SecureTemplateRenderer {
    private OutputSanitizer $sanitizer;
    private string $templatesDir;

    public function construct(
        OutputSanitizer $sanitizer,
        string $templatesDir
    ) {
        $this->sanitizer = $sanitizer;
        $this->templatesDir = rtrim($templatesDir, '/');
    }

    /
      Rend un template avec auto-échappement
     /
    public function render(string $template, array $data = []): string {
        $templatePath = $this->resolveTemplatePath($template);

        if (!fileexists($templatePath)) {
            throw new RuntimeException("Template not found: {$template}");
        }

        // Créer un contexte sécurisé pour les variables
        $context = new class($data, $this->sanitizer) {
            private array $data;
            private OutputSanitizer $sanitizer;

            public function construct(array $data, OutputSanitizer $sanitizer) {
                $this->data = $data;
                $this->sanitizer = $sanitizer;
            }

            public function get(string $key, string $escape = 'html'): string {
                $value = $this->data[$key] ?? '';

                return match ($escape) {
                    'html' => $this->sanitizer->escapeHtml($value),
                    'attr' => $this->sanitizer->escapeAttribute($value),
                    'url' => $this->sanitizer->escapeUrl($value),
                    'js' => $this->sanitizer->escapeJs($value),
                    'none' => $value,
                    default => $this->sanitizer->escapeHtml($value),
                };
            }

            public function raw(string $key): mixed {
                return $this->data[$key] ?? null;
            }
        };

        obstart();
        require $templatePath;
        return obgetclean();
    }

    private function resolveTemplatePath(string $template): string {
        // Empêcher la traversée de répertoire
        $template = strreplace(['../', '..'], '', $template);
        $template = ltrim($template, '/');

        return $this->templatesDir . '/' . $template;
    }
}

// Utilisation dans un template
// templates/product-card.php
?>
### = $context->get('title') ?>

= $context->get('description') ?>

= $context->get('buttontext') ?>

A04:2021 – Insecure Design

La conception sécurisée doit être intégrée dès le début. Implémentons un système de rate limiting robuste.


  Service de rate limiting
 /
class RateLimiter {
    private CacheInterface $cache;
    private int $maxAttempts;
    private int $decayMinutes;

    public function construct(
        CacheInterface $cache,
        int $maxAttempts = 5,
        int $decayMinutes = 1
    ) {
        $this->cache = $cache;
        $this->maxAttempts = $maxAttempts;
        $this->decayMinutes = $decayMinutes;
    }

    /
      Vérifie si la limite est atteinte
     /
    public function tooManyAttempts(string $key): bool {
        return $this->attempts($key) >= $this->maxAttempts;
    }

    /
      Incrémente les tentatives
     /
    public function hit(string $key, int $decayMinutes = null): int {
        $decayMinutes = $decayMinutes ?? $this->decayMinutes;

        $cacheKey = $this->getCacheKey($key);
        $attempts = (int) $this->cache->get($cacheKey) + 1;

        $this->cache->set($cacheKey, $attempts, $decayMinutes  60);

        return $attempts;
    }

    /
      Récupère le nombre de tentatives
     /
    public function attempts(string $key): int {
        return (int) $this->cache->get($this->getCacheKey($key));
    }

    /
      Réinitialise les tentatives
     /
    public function clear(string $key): void {
        $this->cache->delete($this->getCacheKey($key));
    }

    /
      Récupère le nombre de secondes avant disponibilité
     /
    public function availableIn(string $key): int {
        $cacheKey = $this->getCacheKey($key);
        // WordPress transients ne fournissent pas le TTL restant
        // Utiliser une clé séparée pour le timestamp
        $timestampKey = $cacheKey . ':timestamp';
        $timestamp = $this->cache->get($timestampKey);

        if (!$timestamp) {
            return 0;
        }

        $availableAt = $timestamp + ($this->decayMinutes  60);
        $now = time();

        return max(0, $availableAt - $now);
    }

    /
      Génère une clé de cache unique par utilisateur/IP/action
     /
    private function getCacheKey(string $key): string {
        return "ratelimit:{$key}";
    }

    /
      Crée une clé basée sur l'IP et l'action
     /
    public function keyForIpAndAction(string $action): string {
        return $this->getClientIp() . ':' . $action;
    }

    /
      Crée une clé basée sur l'utilisateur et l'action
     /
    public function keyForUserAndAction(int $userId, string $action): string {
        return "user:{$userId}:{$action}";
    }

    private function getClientIp(): string {
        $headers = ['HTTPCFCONNECTINGIP', 'HTTPXFORWARDEDFOR', 'REMOTEADDR'];

        foreach ($headers as $header) {
            if (!empty($SERVER[$header])) {
                $ip = $SERVER[$header];
                if (strpos($ip, ',') !== false) {
                    $ip = explode(',', $ip)[0];
                }
                return trim($ip);
            }
        }

        return 'unknown';
    }
}

/
  Middleware de rate limiting pour requêtes AJAX
 /
class RateLimitMiddleware {
    private RateLimiter $rateLimiter;

    public function construct(RateLimiter $rateLimiter) {
        $this->rateLimiter = $rateLimiter;
    }

    /
      Applique le rate limiting sur une action
     
      @throws RateLimitException
     /
    public function handle(
        string $action,
        int $maxAttempts = null,
        int $decayMinutes = null
    ): void {
        $key = $this->rateLimiter->keyForIpAndAction($action);

        if ($this->rateLimiter->tooManyAttempts($key)) {
            $retryAfter = $this->rateLimiter->availableIn($key);

            throw new RateLimitException(
                "Too many attempts. Try again in {$retryAfter} seconds.",
                $retryAfter
            );
        }

        $this->rateLimiter->hit($key, $decayMinutes);
    }

    /
      Applique le rate limiting sur une action utilisateur
     /
    public function handleForUser(
        string $action,
        int $maxAttempts = null,
        int $decayMinutes = null
    ): void {
        if (!isuserloggedin()) {
            throw new RuntimeException('User must be authenticated');
        }

        $key = $this->rateLimiter->keyForUserAndAction(
            getcurrentuserid(),
            $action
        );

        if ($this->rateLimiter->tooManyAttempts($key)) {
            $retryAfter = $this->rateLimiter->availableIn($key);

            throw new RateLimitException(
                "Too many attempts. Try again in {$retryAfter} seconds.",
                $retryAfter
            );
        }

        $this->rateLimiter->hit($key, $decayMinutes);
    }
}

class RateLimitException extends RuntimeException {
    private int $retryAfter;

    public function construct(string $message, int $retryAfter) {
        parent::construct($message);
        $this->retryAfter = $retryAfter;
    }

    public function getRetryAfter(): int {
        return $this->retryAfter;
    }
}

// Utilisation dans un endpoint de connexion
addaction('wpajaxnoprivcustomlogin', function() {
    $rateLimiter = app(RateLimitMiddleware::class);

    try {
        // Maximum 5 tentatives par minute par IP
        $rateLimiter->handle('loginattempt', 5, 1);

        // Logique de connexion...

    } catch (RateLimitException $e) {
        wpsendjsonerror([
            'message' => $e->getMessage(),
            'retryafter' => $e->getRetryAfter(),
        ], 429);
    }
});

A05:2021 – Security Misconfiguration

La configuration sécurisée est essentielle. Voici un hardening complet.


  Service de durcissement de la sécurité WordPress
 /
class WordPressHardening {
    /
      Applique tous les durcissements
     /
    public function apply(): void {
        $this->disableXmlRpc();
        $this->disableFileEditing();
        $this->hideWordPressVersion();
        $this->removeGeneratorTags();
        $this->secureRestApi();
        $this->addSecurityHeaders();
        $this->disableUserEnumeration();
        $this->limitLoginAttempts();
    }

    /
      Désactive XML-RPC (vecteur d'attaque commun)
     /
    private function disableXmlRpc(): void {
        addfilter('xmlrpcenabled', 'returnfalse');

        // Bloquer complètement l'accès
        addaction('init', function() {
            if (isset($SERVER['REQUESTURI']) &&
                strpos($SERVER['REQUESTURI'], '/xmlrpc.php') !== false) {
                httpresponsecode(403);
                exit('Forbidden');
            }
        });
    }

    /
      Désactive l'édition de fichiers depuis l'admin
     /
    private function disableFileEditing(): void {
        if (!defined('DISALLOWFILEEDIT')) {
            define('DISALLOWFILEEDIT', true);
        }
    }

    /
      Masque la version de WordPress
     /
    private function hideWordPressVersion(): void {
        removeaction('wphead', 'wpgenerator');

        addfilter('thegenerator', function() {
            return '';
        });
    }

    /
      Supprime les tags meta révélateurs
     /
    private function removeGeneratorTags(): void {
        removeaction('wphead', 'rsdlink');
        removeaction('wphead', 'wlwmanifestlink');
        removeaction('wphead', 'wpshortlinkwphead');
    }

    /
      Sécurise l'API REST
     /
    private function secureRestApi(): void {
        // Désactiver REST API pour les non-authentifiés (optionnel)
        addfilter('restauthenticationerrors', function($result) {
            if (!isuserloggedin()) {
                // Autoriser certains endpoints publics
                $publicEndpoints = [
                    '/wp/v2/posts',
                    '/wp/v2/pages',
                ];

                $requestUri = $SERVER['REQUESTURI'] ?? '';
                foreach ($publicEndpoints as $endpoint) {
                    if (strpos($requestUri, $endpoint) !== false) {
                        return $result;
                    }
                }

                return new WPError(
                    'restnotloggedin',
                    'You must be logged in to access the API.',
                    ['status' => 401]
                );
            }

            return $result;
        });

        // Supprimer l'en-tête révélateur
        removeaction('templateredirect', 'restoutputlinkheader', 11);
    }

    /
      Ajoute des en-têtes de sécurité HTTP
     /
    private function addSecurityHeaders(): void {
        addaction('sendheaders', function() {
            // Content Security Policy
            header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self';");

            // X-Frame-Options (protection contre clickjacking)
            header('X-Frame-Options: SAMEORIGIN');

            // X-Content-Type-Options
            header('X-Content-Type-Options: nosniff');

            // X-XSS-Protection
            header('X-XSS-Protection: 1; mode=block');

            // Referrer-Policy
            header('Referrer-Policy: strict-origin-when-cross-origin');

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

            // HSTS (HTTP Strict Transport Security) - uniquement en HTTPS
            if (isssl()) {
                header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
            }
        });
    }

    /
      Empêche l'énumération des utilisateurs
     /
    private function disableUserEnumeration(): void {
        // Bloquer les requêtes ?author=N
        addaction('init', function() {
            if (isset($GET['author']) && !isadmin()) {
                wpdie('Forbidden', 'Forbidden', ['response' => 403]);
            }
        });

        // Masquer les noms d'utilisateur dans les classes body
        addfilter('bodyclass', function($classes) {
            return arrayfilter($classes, function($class) {
                return strpos($class, 'author-') !== 0;
            });
        });

        // Masquer les noms d'utilisateur dans REST API
        addfilter('restprepareuser', function($response, $user, $request) {
            if (!currentusercan('listusers')) {
                $response->data['name'] = 'User';
                $response->data['slug'] = '';
                $response->data['link'] = '';
            }
            return $response;
        }, 10, 3);
    }

    /
      Limite les tentatives de connexion
     /
    private function limitLoginAttempts(): void {
        addfilter('authenticate', function($user, $username, $password) {
            if (empty($username) || empty($password)) {
                return $user;
            }

            $rateLimiter = app(RateLimiter::class);
            $key = $rateLimiter->keyForIpAndAction('login');

            if ($rateLimiter->tooManyAttempts($key)) {
                $retryAfter = $rateLimiter->availableIn($key);

                return new WPError(
                    'toomanyattempts',
                    sprintf(
                        'Too many login attempts. Please try again in %d seconds.',
                        $retryAfter
                    )
                );
            }

            if (iswperror($user)) {
                $rateLimiter->hit($key, 15); // 15 minutes de blocage
            } else {
                $rateLimiter->clear($key);
            }

            return $user;
        }, 30, 3);
    }
}

// Activation dans le plugin
addaction('pluginsloaded', function() {
    $hardening = new WordPressHardening();
    $hardening->apply();
});

Audit de Sécurité et Monitoring


  Logger d'événements de sécurité
 /
class SecurityLogger {
    private string $logFile;

    public function construct(string $logFile = null) {
        $this->logFile = $logFile ?? WPCONTENTDIR . '/security.log';
    }

    /
      Enregistre un événement de sécurité
     */
    public function log(
        string $event,
        string $severity,
        array $context = []
    ): void {
        $entry = [
            'timestamp' => gmdate('Y-m-d H:i:s'),
            'event' => $event,
            'severity' => $severity,
            'userid' => getcurrentuserid(),
            'ip' => $this->getClientIp(),
            'useragent' => $SERVER['HTTPUSERAGENT'] ?? '',
            'context' => $context,
        ];

        $line = jsonencode($entry) . PHPEOL;

        errorlog($line, 3, $this->logFile);

        // Alertes critiques
        if ($severity === 'CRITICAL') {
            $this->sendAlert($entry);
        }
    }

    private function sendAlert(array $entry): void {
        $adminEmail = getoption('adminemail');

        wpmail(
            $adminEmail,
            '[SECURITY ALERT] ' . $entry['event'],
            $this->formatAlertEmail($entry),
            ['Content-Type: text/html; charset=UTF-8']
        );
    }

    private function formatAlertEmail(array $entry): string {
        return sprintf(
            "

Security Alert

Event: %s

Time: %s

User ID: %s

IP Address: %s

Details:

%s

",
eschtml($entry['event']),
esc
html($entry['timestamp']),
eschtml($entry['userid']),
eschtml($entry['ip']),
esc
html(printr($entry['context'], true))
);
}

private function getClientIp(): string {
$headers = ['HTTPCFCONNECTINGIP', 'HTTPXFORWARDEDFOR', 'REMOTEADDR'];

foreach ($headers as $header) {
if (!empty($SERVER[$header])) {
$ip = $
SERVER[$header];
if (strpos($ip, ',') !== false) {
$ip = explode(',', $ip)[0];
}
return trim($ip);
}
}

return 'unknown';
}
}

// Hooks d'audit
addaction('wploginfailed', function($username) {
$logger = app(SecurityLogger::class);
$logger->log('Login Failed', 'WARNING', ['username' => $username]);
});

addaction('wplogin', function($username, $user) {
$logger = app(SecurityLogger::class);
$logger->log('Successful Login', 'INFO', [
'username' => $username,
'user
id' => $user->ID,
]);
}, 10, 2);

Conclusion

La sécurité WordPress en 2025 nécessite une approche multicouche intégrant :

Une remarque, un retour ?

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