La semaine où j'ai décidé de supprimer tous les plugins de sécurité de mon WordPress, j'ai mis du temps à me rendre compte de ce que je venais de faire. Ce n'était pas un geste de confiance dans WordPress. C'était exactement l'inverse : un constat qu'on ne peut pas demander à PHP de se défendre contre des attaques qui visent PHP. Le code source de toute l'expérimentation est disponible sur GitHub. Ce n'est pas un template prêt pour la prod. C'est une archive publique d'expérimentations — et c'est écrit noir sur blanc dans le README.
WordPress fait tourner plus de 40 % du web mondial. Ce chiffre est à la fois sa force et sa malédiction : quand vous êtes la cible la plus évidente, vous êtes aussi celle qui attire le plus d'attention malveillante. La réponse traditionnelle de la communauté, depuis quinze ans, c'est d'empiler les plugins de sécurité. Wordfence. Sucuri. iThemes Security. All In One WP Security. On ajoute une couche PHP pour surveiller PHP.
Je faisais comme tout le monde. Et puis un jour j'ai pris du recul et j'ai regardé ce que je demandais concrètement à ces plugins. La réponse m'a mis mal à l'aise : je leur demandais d'intercepter des requêtes HTTP malveillantes depuis l'intérieur de l'application qu'elles visent. C'est comme installer un vigile à l'intérieur du coffre-fort plutôt qu'à l'entrée de la banque.
Alors j'ai ouvert un repo vide, j'ai pris un WordPress Bedrock, je l'ai poussé sur Upsun (l'ancien Platform.sh), et je me suis fixé une règle : aucun plugin de sécurité. Tout devait se passer en amont de PHP, dans la configuration d'infrastructure, en Infrastructure as Code.
Le paradoxe des plugins de sécurité
Avant de décrire ce que j'ai fait, je veux verbaliser pourquoi l'approche "plugin PHP" est structurellement boiteuse. Il y a quatre problèmes qui se cumulent.
Premier problème : un plugin, c'est du code qui augmente votre surface d'attaque. C'est contre-intuitif, mais c'est mécanique. Chaque plugin de sécurité, c'est quelques milliers de lignes de PHP supplémentaires qui tournent à chaque requête. Ces lignes peuvent elles-mêmes contenir des failles. Les CVE sur les plugins de sécurité eux-mêmes ne sont pas une rareté — elles arrivent chaque année, et chacune est un paradoxe cruel : vous avez installé un plugin pour vous protéger, et ce plugin est devenu le point d'entrée.
Deuxième problème : ils interviennent trop tard. C'est le point que je trouve le plus important. Quand une requête vers /wp-content/uploads/shell.php arrive sur votre serveur, voici ce qui se passe avant que votre plugin de sécurité puisse faire quoi que ce soit : le load balancer l'accepte, Nginx la route, PHP-FPM démarre un worker, WordPress boot (on parle de quelques dizaines de mégaoctets en mémoire), les plugins se chargent, et enfin votre plugin de sécurité se réveille et dit "ah non, pas celle-là". L'attaquant a déjà consommé du CPU, de la RAM, et a eu l'occasion d'exploiter n'importe quelle faille dans le chemin de chargement.
Troisième problème : la configuration vit en base de données. Les réglages de ces plugins sont stockés dans wp_options. Ce n'est pas versionnable. Ce n'est pas auditable via git blame. Ce n'est pas reproductible sur un autre environnement. Si vous perdez la base, vous perdez votre posture de sécurité. Si un collègue modifie un réglage dans le dashboard, personne ne le sait. C'est l'inverse exact de tout ce que DevOps a essayé de nous apprendre depuis dix ans.
Quatrième problème : ils créent un faux sentiment de sécurité. Un cadenas vert dans le dashboard WordPress ne signifie pas que votre site est sécurisé. Il signifie qu'un plugin a vérifié ce qu'il sait vérifier. Ce qu'il ne vérifie pas, vous ne le saurez jamais, parce que vous n'avez pas lu les 50 000 lignes de code du plugin.
Defense in Depth : remonter les contrôles au plus bas possible
L'approche que je vais décrire est une application du principe de défense en profondeur (Defense in Depth) : on ne met pas tous ses œufs dans une seule couche de sécurité, on en empile plusieurs, et surtout on met chaque contrôle au niveau le plus bas possible de la stack.
Sur un WordPress hébergé sur Upsun, la stack ressemble à ça :
Requête HTTP entrante
└─► Routeur Upsun (TLS, cache, redirections) ← Couche 1
└─► Nginx (locations, rules, headers) ← Couche 2
└─► PHP-FPM (OPcache, disable_functions) ← Couche 3
└─► WordPress (logique métier)
L'idée, c'est que quand une requête atteint réellement WordPress, elle a déjà été triée par trois couches en amont. WordPress ne gère plus la sécurité périmétrique — il gère uniquement ce pour quoi il est fait : produire des pages à partir d'une base de données.
Concrètement, dans mon expérimentation, tout tient dans trois fichiers versionnés dans Git :
.upsun/config.yaml— la configuration du routeur et de Nginxweb/app/mu-plugins/upsun-security.php— un mu-plugin qui durcit ce qui doit rester dans PHPweb/app/mu-plugins/health-check.php— un endpoint de monitoring qui ne charge pas WordPress
Trois fichiers. Pas cinquante plugins dans une base de données. Voyons ce qu'il y a dedans.
Ce qui se passe au niveau Nginx
La première couche de défense, c'est le bloc web.locations."/" du fichier config.yaml d'Upsun. Il décrit les règles que Nginx applique avant que PHP soit invoqué — et donc avant que WordPress soit chargé en mémoire. Une requête qui matche une règle interdite est rejetée en quelques microsecondes, sans jamais allumer un worker PHP-FPM.
Voici les règles les plus importantes, extraites directement du repo :
rules:
# Fichiers sensibles : jamais servir .env, .git, composer.*
'\.(env|git|composer\.json|composer\.lock|auth\.json)$':
allow: false
# Config, backup, docs (Defense in Depth)
'\.(yml|yaml|dist|sql|log|sh|bak|ini|swp|md|markdown)$':
allow: false
# Tentatives de path traversal
'^//+':
allow: false
# PHP dans /uploads : vecteur classique de webshell
'^/app/uploads/.*\.(php[0-9]?|phtml|inc)$':
allow: false
# PHP direct dans /plugins et /themes : empêche le bypass
'^/app/(plugins|themes)/.*\.php$':
allow: false
# Divulgation de version WordPress
'^(?:/wp)?/(?:readme|license|changelog|-config|-sample)\.(?:html|txt|php)':
allow: false
# wp-login.php et wp-admin à la racine : 404 pour tout le monde
'^/wp/wp-login\.php': { allow: false }
'^/wp-login\.php': { allow: false }
'^/wp-admin': { allow: false }
# Scripts d'installation (inutiles après déploiement)
'^/wp/wp-admin/(install|upgrade)\.php':
allow: false
# XML-RPC : vecteur de brute-force et amplification DDoS
'^/wp/xmlrpc\.php': { allow: false }
'^/xmlrpc\.php': { allow: false }
# User enumeration via l'API REST
'^/wp-json/wp/v2/users':
allow: false
Prenez n'importe laquelle de ces règles et comparez-la à ce que fait un plugin de sécurité classique. Wordfence peut bloquer l'énumération d'utilisateurs via l'API REST — mais il le fait après que WordPress ait chargé le plugin REST, instancié le contrôleur, et commencé à exécuter la requête. Nginx le fait en quelques microsecondes. Pour un site à fort trafic qui reçoit des centaines de scans automatisés par heure, c'est la différence entre un serveur qui transpire et un serveur qui dort.
Un détail qui mérite d'être souligné : le blocage de wp-login.php au niveau Nginx. Dans mon expérimentation, j'utilise un "secret admin path" piloté par variable d'environnement : l'URL de connexion à WordPress devient https://monsite.com/mon-secret-vraiment-random-2026, réservée à moi, et l'URL publique wp-login.php renvoie un 404 à tout le monde. Les bots qui passent leur journée à tester des mots de passe sur /wp-login.php ne trouvent plus rien à attaquer.
Des headers de sécurité modernes, au bon endroit
Les headers HTTP de sécurité sont un autre exemple typique de ce qu'on confie traditionnellement à un plugin alors qu'ils devraient vivre au niveau serveur. Dans config.yaml, c'est une section de quelques lignes :
headers:
X-Frame-Options: "SAMEORIGIN"
X-Content-Type-Options: "nosniff"
Referrer-Policy: "strict-origin-when-cross-origin"
Permissions-Policy: "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()"
Petit commentaire sur ce qui n'est pas là : le fameux X-XSS-Protection. Je l'ai volontairement omis. Ce header est déprécié depuis 2020 et peut introduire des vulnérabilités dans certains navigateurs — Chrome l'a désactivé par défaut, Firefox ne l'a jamais implémenté. La plupart des guides de sécurité WordPress le recommandent encore. C'est typiquement le genre de dette que les plugins ont du mal à éliminer.
Pour la Permissions-Policy, je désactive par défaut tout ce dont un site WordPress n'a jamais besoin : caméra, micro, géolocalisation, paiement, USB. Et interest-cohort=(), qui désactive FLoC — le système de tracking de Google qu'on n'a pas envie de voir revenir.
Content Security Policy : quatre modes pilotés par variable d'environnement
La CSP, c'est le header de sécurité le plus puissant — et le plus casse-pieds à mettre en place. Une CSP stricte casse à peu près tout ce qui traîne en inline dans WordPress : scripts de plugins, styles injectés, handlers d'événements, et j'en passe.
Mon approche dans le repo consiste à piloter la CSP via une variable d'environnement UPSUN_CSP_MODE qui accepte quatre valeurs :
// Dans upsun-security.php
// UPSUN_CSP_MODE:
// 'off' : pas de CSP (pas recommandé)
// 'permissive' : CSP avec unsafe-inline (compat WordPress)
// 'report-only' : CSP stricte en mode observation (recommandé au départ)
// 'strict' : CSP stricte bloquante (objectif final)
Le principe, c'est qu'on peut progresser graduellement vers une CSP stricte sans rien casser en production. On commence en permissive pour que tout marche, on passe en report-only en staging pour collecter les violations sans bloquer, on les corrige une par une, et le jour où on passe en strict en production, on a déjà vérifié qu'il n'y a plus de faux positif. À chaque requête, le mu-plugin génère un nonce unique (base64_encode(random_bytes(16))) qui est injecté automatiquement dans tous les tags <script> émis par WordPress via le filtre script_loader_tag. Résultat : le code inline légitime passe, celui injecté par un attaquant est bloqué.
Ce que je trouve élégant dans cette approche, c'est qu'elle est versionnée. Quand je passe de report-only à strict, c'est un commit. Un autre développeur peut review la PR. Si ça casse, on rollback en une commande. Comparez ça à un plugin qui stocke son réglage CSP dans wp_options.
Un Nano-WAF écrit en PHP dans un mu-plugin
C'est la partie la plus amusante de l'expérimentation. Toutes les règles Nginx dont on a parlé plus haut traitent les patterns d'URL. Mais il reste une catégorie d'attaques qui ne se voit pas dans l'URL : celles qui exploitent les en-têtes HTTP. Méthode TRACE pour faire des cross-site tracing, User-Agent de scanner qui fingerprinte votre site, fingerprinting applicatif via des headers bizarres.
Pour ça, je laisse un tout petit morceau de code PHP intervenir — mais avant que WordPress ne soit chargé, via un mu-plugin qui se déclenche sur muplugins_loaded (voire plus tôt). C'est ce que j'ai baptisé un Nano-WAF : trois cents lignes qui font un filtrage périmètre de base.
Premier filtre : les méthodes HTTP dangereuses.
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$blocked_methods = ['TRACE', 'TRACK', 'DEBUG', 'CONNECT'];
if (in_array(strtoupper($method), $blocked_methods)) {
status_header(405);
header('Allow: GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS');
exit;
}
Ces quatre méthodes n'ont aucune raison légitime d'être acceptées par un site WordPress. Elles existent pour le débogage ou le proxying HTTP. Les bloquer est gratuit et ferme un vecteur entier d'attaques.
Deuxième filtre : un allowlist/blocklist d'User-Agents qui distingue trois catégories :
- Bypass systématique pour
/health(le monitoring a besoin d'accéder sans friction). - Allowlist des services légitimes — et là j'ai vraiment pris le temps de cataloguer : les bots de moteurs de recherche (Googlebot, Bingbot, DuckDuckBot, Qwant, Ecosia), les webhooks de paiement (Stripe, PayPal, Mollie), les outils de monitoring (Pingdom, UptimeRobot, Datadog), les crawlers d'IA (ClaudeBot, GPTBot, PerplexityBot, Google-Extended, ChatGPT-User — je veux que mon contenu soit indexé par les LLMs), et j'en passe.
- Blocklist des scanners d'attaque connus :
sqlmap,nikto,nmap,masscan,nuclei,wpscan,joomscan,dirbuster,gobuster,burpsuite,metasploit, et toute la collection. Quand un de ces user-agents est détecté, le serveur répond 404 — et pas 403. C'est délibéré : un 403 révèle à l'attaquant qu'on l'a vu venir. Un 404 lui fait croire que la ressource n'existe pas. On appelle ça le stealth mode, et ça réduit significativement le bruit des logs.
Le design est fail-open : un User-Agent inconnu passe. Je ne veux surtout pas bloquer un nouveau service légitime que je n'aurais pas prévu dans l'allowlist. La seule chose qui fait fail-close, c'est la détection positive d'un scanner.
Troisième filtre : un path filtering en mode stealth 404. Les chemins /wp-admin à la racine, /wp-login.php à la racine, xmlrpc.php à la racine, /wp/readme.html, /wp/license.txt, etc., sont déjà bloqués par Nginx — mais le mu-plugin fait un double-check et retourne aussi un 404 avec un header X-Robots-Tag: noindex, nofollow. Double rideau.
OPcache : tirer parti de l'immuabilité du filesystem
Upsun, comme Platform.sh et la plupart des PaaS modernes, construit votre application dans une image immuable. Une fois déployée, le filesystem applicatif est en lecture seule. Le seul moyen de modifier du code, c'est de faire un nouveau déploiement.
Cette contrainte, qui peut paraître pénible au premier abord, est en réalité un cadeau pour la sécurité et la performance. Pour la sécurité : un attaquant qui réussit à uploader un fichier PHP quelque part ne pourra pas l'exécuter, parce que la zone où il peut écrire (/uploads, montée en volume) est bloquée par les règles Nginx qu'on a vues plus haut. Et pour la performance, ça change tout ce qu'on peut faire avec OPcache.
OPcache, c'est le cache de bytecode PHP. Par défaut, il vérifie périodiquement si les fichiers PHP ont changé sur le disque (opcache.validate_timestamps=1). Sur un PaaS immuable, c'est inutile : les fichiers ne peuvent pas changer. On peut donc configurer :
php:
opcache.enable: 1
opcache.memory_consumption: 128
opcache.interned_strings_buffer: 16
opcache.max_accelerated_files: 20000
opcache.validate_timestamps: 0
opcache.revalidate_freq: 0
Le paramètre clé, c'est validate_timestamps: 0. PHP ne va jamais revérifier les fichiers. Tout est servi depuis la mémoire du worker. Le disque n'est touché qu'au tout premier chargement d'un fichier. Je n'ai pas de benchmark chiffré à vous montrer dans ce papier (ce sera pour un prochain article dédié), mais l'effet qualitatif est tangible : les pages chargent plus vite, et la charge CPU est visiblement plus basse.
Un health-check qui ne charge pas WordPress
Dernière pièce du puzzle, et probablement la plus sous-estimée. Pour faire du monitoring externe, on a besoin d'un endpoint qui dit "tout va bien" ou "quelque chose ne va pas". La tentation, c'est d'utiliser l'URL d'accueil : un monitoring qui fait GET / toutes les minutes.
C'est une terrible idée. À chaque check, vous bootez WordPress en entier : chargement des plugins, des thèmes, des hooks, requêtes en base. Pour un monitoring qui check toutes les 30 secondes, c'est plusieurs dizaines de chargements WordPress par minute juste pour dire "ça marche".
Mon approche dans le repo : un mu-plugin health-check.php qui répond sur /health, vérifie PHP, MariaDB et Redis, retourne un JSON, et ne charge pas WordPress. Techniquement, il définit WP_USE_THEMES = false et WP_INSTALLING = true avant d'inclure wp-config.php, ce qui empêche la majorité des plugins de s'accrocher. Sur Upsun, j'ai même mieux : dans config.yaml, j'ai défini une location dédiée /health qui pointe directement sur le fichier PHP, court-circuitant complètement le routeur WordPress :
"/health":
root: "web/app/mu-plugins"
passthru: "/health-check.php"
allow: true
scripts: true
headers:
Access-Control-Allow-Origin: "*"
Résultat : le check ne traverse pas WordPress du tout. C'est un endpoint autonome qui vit dans le même processus PHP-FPM mais qui n'a aucune dépendance avec WordPress. Il peut répondre même si WordPress est cassé — ce qui est exactement ce qu'on attend d'un health-check. Pour un monitoring externe, c'est plus fiable. Pour les déploiements zero-downtime, c'est indispensable.
Supply chain security : auditer ce qu'on installe
Un dernier morceau qui sort du scope "sécurité périmétrique" mais qui mérite d'être mentionné, parce qu'il complète le tableau. Dans le hook de build Upsun, j'exécute deux commandes :
composer install --no-dev --optimize-autoloader --prefer-dist
composer audit --locked
La deuxième commande lit le composer.lock et compare chaque dépendance à la base de données publique des vulnérabilités connues. Si une CVE existe sur un package installé, le build le signale. C'est le genre de contrôle qui, dans un workflow WordPress classique, n'existe tout simplement pas — parce qu'il n'y a ni composer.lock, ni pipeline de build, ni workflow versionné.
Et en post-deploy, un autre contrôle :
wp core verify-checksums
Cette commande WP-CLI demande à WordPress.org les checksums officiels du core et les compare aux fichiers installés. Si un fichier du core a été modifié (backdoor, altération), elle le détecte. Gratuit, idempotent, exécuté à chaque déploiement.
Ce que ça change concrètement
Je n'ai pas de tableau de benchmarks à vous vendre — ce serait malhonnête sans avoir mené une comparaison rigoureuse côte à côte avec une installation équivalente sous plugins. Ce que je peux dire, ce sont les observations qualitatives que j'ai tirées de cette expérimentation :
- La surface d'audit est radicalement plus petite. Trois fichiers dans Git, au lieu de cinquante plugins et une table
wp_options. Un auditeur sécurité peut lire la totalité de la configuration de sécurité en quinze minutes. - La configuration est reproductible. Je peux spinner un nouvel environnement de staging en une commande (
upsun environment:branch) et récupérer exactement la même posture de sécurité. Sur un WordPress classique, ce genre de parité staging/prod demande des exports de base, des migrations de réglages, et beaucoup d'approximation. - Les modifications de sécurité passent par la PR review. Quand je veux passer la CSP de
report-onlyàstrict, c'est un commit, une PR, une review. Git blame me dit qui a changé quoi et quand. Dans un WordPress classique, ce même changement est une coche dans un dashboard, invisible à tout le monde. - Le WAF n'existe pas en dehors du process PHP. C'est un nano-WAF, pas un vrai WAF managé. Il ne remplace pas Cloudflare ou Akamai. Il ne protège pas contre les DDoS volumétriques. Il est là pour fermer les vecteurs évidents, pas pour traiter le trafic sophistiqué.
Les limites — et ce que les plugins font encore mieux
Je ne veux pas laisser croire qu'on peut tout remplacer par de l'Infrastructure as Code. Il y a plusieurs choses que les plugins de sécurité font encore bien mieux qu'une configuration infra :
- Le scanning de malware sur les fichiers existants. Un Wordfence qui scanne
/wp-content/uploadsà la recherche de signatures connues, ça reste utile sur un site établi qui a peut-être déjà été compromis. - L'authentification à deux facteurs. C'est de la logique applicative, ça vit naturellement dans WordPress, et des plugins comme Two Factor (géré par l'équipe core) le font très bien.
- Le firewall applicatif avancé. Les règles métier spécifiques à WordPress (comme la détection des tentatives d'injection dans les champs de formulaire Elementor) sont mieux traitées par un plugin qui comprend le contexte applicatif.
- Les rapports de conformité. Pour RGPD, PCI-DSS et autres, les plugins produisent les rapports que les auditeurs attendent dans le format qu'ils attendent.
L'approche que je défends, ce n'est pas "zéro plugin de sécurité pour toujours". C'est "l'infrastructure gère la sécurité périmétrique, et les plugins ne traitent plus que ce qui doit vivre dans le domaine applicatif". En pratique, ça réduit la surface de plugins de sécurité à zéro ou à un seul (Two Factor), au lieu de cinq ou dix.
Conclusion : pourquoi cette expérimentation m'a changé
Ce que j'ai retiré de cette session d'expérimentation, ce n'est pas tant une recette à copier-coller qu'un changement de posture mentale. Tant qu'on continue à demander à PHP de se protéger lui-même, on accepte que la sécurité soit un problème résolu par du code métier. Dès qu'on remonte les contrôles dans la configuration d'infrastructure, on change de monde : la sécurité devient un problème d'infrastructure, versionné, reviewé, auditable, reproductible. Ce n'est plus une couche qu'on ajoute. C'est une propriété qu'on déclare.
Ça rejoint quelque chose que j'observe depuis quelques années dans tous mes projets B2B et B2G : la valeur ne vient plus du code qu'on écrit, elle vient de la posture d'exploitation qu'on se donne. Un site bien configuré avec du WordPress Core à jour et trois fichiers d'infrastructure bien pensés est plus défendable qu'un site avec cinquante plugins de sécurité et un dashboard plein de voyants verts.
Le code source de cette expérimentation est disponible sur GitHub : rdelfosse/upsun-wp-exploration. Ce n'est ni un template prêt pour la production, ni un tutoriel à suivre les yeux fermés. C'est un concept-car : une vitrine de ce qu'il est possible d'empiler quand on pousse la logique d'Infrastructure as Code à son maximum sur un WordPress. À utiliser comme source d'inspiration, à adapter à votre contexte, à challenger.
Cet article est le premier d'une série sur la modernisation des stacks WordPress. Prochain épisode prévu : CI/CD et déploiements zero-downtime — ou comment arrêter de faire vos mises à jour en FTP.

