Tout va bien jusqu’à ce que ça ne va plus. L’éditeur refuse d’enregistrer. Le personnalisateur tourne indéfiniment. Votre bouton « Charger plus d’articles » cesse de charger quoi que ce soit. DevTools montre une jolie requête vers /wp-admin/admin-ajax.php qui renvoie 400 ou 403, et l’équipe métier demande pourquoi « le site refuse de cliquer ».
admin-ajax.php est l’un des plus anciens chevaux de travail de WordPress. C’est aussi un aimant pour les contrôles de sécurité, les erreurs de mise en cache et les optimisations « utiles ». La bonne nouvelle : 400/403 est habituellement un blocage volontaire. Votre travail est de trouver quelle couche dit non, et pourquoi.
Playbook de diagnostic rapide
Ceci est le chemin « obtenir un signal en cinq minutes ». Ce n’est pas élégant. C’est efficace.
1) Confirmez où le 400/403 est généré : edge, WAF, serveur web ou WordPress
- Regardez les en-têtes de réponse dans DevTools pour des indices :
server:,cf-ray,x-sucuri-id,x-mod-security,x-cache,via. - Vérifiez le corps : les produits WAF renvoient souvent du HTML brandé, du JSON, ou un « Accès refusé » générique. WordPress retourne typiquement
0ou une petite chaîne quand un handler AJAX meurt tôt.
2) Reproduisez depuis le serveur et depuis l’extérieur
- Si la requête échoue depuis le serveur lui-même mais fonctionne depuis votre portable, suspectez le pare-feu de l’hébergeur, SELinux, un proxy inverse local ou le routage vhost.
- Si elle échoue depuis l’extérieur mais fonctionne localement, suspectez le CDN/WAF ou des contrôles géographiques/IP.
3) Lisez les logs correspondant à la couche
- Logs CDN/WAF en premier s’ils existent. Si vous ne pouvez pas les voir, bypasser temporairement le CDN avec une redirection hosts pour atteindre l’origine et comparer.
- Logs d’accès+erreur Nginx/Apache ensuite. Confirmez que le code d’état provient du serveur web, pas d’un upstream.
- Logs PHP-FPM/WordPress en dernier. Un 403 vient rarement de PHP sauf si un plugin le provoque explicitement.
4) Identifiez le motif précis de requête bloqué
Les appels admin-ajax.php varient. Certains sont des appels admin authentifiés ; d’autres sont publics. Capturez :
- Méthode HTTP (GET vs POST)
- Paramètres :
action, champs nonce, taille de la charge - Cookies (les sessions connectées comptent)
- En-têtes Referer et Origin
5) Décidez : allowlist, corriger la logique de l’app ou changer d’architecture
La plupart des corrections sont l’une de ces options :
- Allowlist les actions AJAX sûres dans le WAF/mod_security avec un périmètre strict.
- Corriger nonces/auth si c’est un problème côté WordPress « vous n’êtes pas autorisé ».
- Arrêter de mettre en cache admin-ajax.php (ou arrêter de mettre en cache des pages qui incorporent des nonces incorrectement).
- Passer à l’API REST pour les interactions publiques à fort volume et réserver admin-ajax aux flux d’administration legacy.
Comment admin-ajax.php fonctionne vraiment (et pourquoi il est bloqué)
admin-ajax.php est le point d’appel RPC à l’ancienne de WordPress. Vous l’appelez avec action=some_hook_name, WordPress se bootstrappe, puis il appelle votre fonction handler si elle est enregistrée sur wp_ajax_{action} (authentifié) ou wp_ajax_nopriv_{action} (non authentifié).
Ce bootstrap est la clé. Pour chaque requête, WordPress charge beaucoup de PHP. Les plugins lourds en chargent davantage. Si vous l’appelez trop souvent — polling, widgets de chat, défilement infini — vous créez effectivement une mini-API qui ne se comporte pas comme une API moderne.
Et parce qu’il vit sous /wp-admin/, les outils de sécurité le traitent comme « admin », même lorsqu’il alimente des interactions frontales. Beaucoup de WAFs incluent des règles WordPress qui ciblent spécifiquement admin-ajax.php pour la protection contre le brute force et les bots. Parfois ils ont raison. Parfois ils bloquent votre checkout.
Un modèle mental utile : admin-ajax est à la fois un plan de contrôle et un point d’accès API public, selon la façon dont les plugins l’utilisent. Les contrôles de sécurité préfèrent qu’il soit le premier. Les plugins l’utilisent souvent comme le second. Le conflit est inévitable.
Blague n°1 : admin-ajax.php est comme la porte du bureau « réservée au personnel », sauf que chaque client la trouve et demande de l’aide.
Ce que 400 vs 403 signifie dans ce contexte
400 Bad Request
400 signifie généralement « le serveur n’a pas aimé ce que vous avez envoyé », mais c’est vague. En pratique, pour admin-ajax un 400 tend à être :
- Requête mal formée : paramètre requis manquant comme
action, type de contenu invalide, encodage cassé, corps tronqué. - Requête trop grande : limites de corps client (
client_max_body_size), limites d’inspection du WAF, limites de taille des en-têtes. - Module de sécurité rejette la charge : mod_security renvoie 400 dans certaines configurations, surtout pour des violations du corps de la requête.
- Inadéquation proxy en amont : HTTP/2 à l’edge, HTTP/1.1 à l’origine avec réécriture d’en-têtes incorrecte, ou un load balancer normalisant mal quelque chose.
403 Forbidden
403 est plus honnête : « Je vous ai compris. Vous n’êtes pas autorisé. » Pour admin-ajax, cela correspond souvent à :
- Règle WAF/CDN bloquant le chemin, la requête, l’IP, le pays, l’ASN, ou un score de menace.
- Règle serveur web (
deny all, absence d’autorisation pour PHP, problèmes de priorité deslocation). - Auth/cookies manquants pour des actions authentifiées.
- Contrôles de nonce/capabilité WordPress échouant et le handler retournant 403.
- Fail2ban ou limitation de débit bloquant l’adresse client.
Faits et contexte intéressants (oui, ça a une histoire)
- admin-ajax.php précède l’ère de l’API REST de WordPress. Il est devenu le « endpoint AJAX » par défaut bien avant que les API JSON modernes ne soient courantes dans l’écosystème WP.
- Historiquement, de nombreux plugins utilisaient admin-ajax pour des actions frontales parce qu’il était universellement disponible et ne nécessitait pas de permaliens propres.
- L’API REST de WordPress est devenue core en 4.7, orientant les bonnes pratiques vers
/wp-json/— pourtant admin-ajax reste omniprésent pour la compatibilité. - admin-ajax est sous /wp-admin/, ce qui fait que les outils de sécurité le considèrent comme du trafic « admin », même quand ce n’est pas le cas.
- Le système de nonces de WordPress est basé sur le temps et lié aux sessions utilisateur ; mettre en cache des pages qui incorporent des nonces peut casser les appels AJAX d’une façon qui ressemble à des « 403 aléatoires ».
- Certaines règles WAF ciblent explicitement des noms de paramètres WordPress courants comme
action,_wpnonce, et des clés spécifiques aux plugins parce que les attaquants les réutilisent. - mod_security renvoie souvent 403, mais peut aussi renvoyer 400 selon qu’il considère le problème comme « accès refusé » ou « corps de requête invalide ».
- admin-ajax est fréquemment abusé pour des DoS car chaque appel peut déclencher un bootstrap complet de WordPress et des opérations lourdes en base de données.
- Beaucoup de snippets de durcissement qui « désactivent l’accès wp-admin » cassent accidentellement admin-ajax parce qu’ils bloquent tout
/wp-admin/sans exceptions.
La carte des couches : tous les endroits où une requête peut mourir
Quand vous voyez 400/403, ne discutez pas le symptôme. Localisez le videur.
Layer 0: Navigateur et JavaScript
- Mauvaise URL d’endpoint : certains sites définissent
ajaxurlincorrectement (schéma mixte, mauvais domaine, mauvais chemin après une migration). - Échec de préflight CORS si vous envoyez des en-têtes personnalisés ou des requêtes cross-origin.
- Inadéquation de type de contenu (
application/jsonenvoyé alors que le serveur attend un encodage de formulaire).
Layer 1: DNS et edge CDN
- Le CDN met en cache une réponse qui ne devrait jamais être mise en cache.
- Le WAF bloque la requête par chemin, user agent, motif de requête ou limitation de débit.
- Les challenges de protection contre les bots brisent les requêtes XHR/fetch en arrière-plan.
Layer 2: Load balancer / proxy inverse
- La normalisation des en-têtes change la forme de la requête.
- Les limites de taille de corps diffèrent entre l’edge et l’origine.
- Restrictions de méthode HTTP : certains proxies bloquent les POST vers des « chemins admin ».
Layer 3: Serveur web (Nginx/Apache)
- Priorité des
locationNginx : un bloc deny large attrapeadmin-ajax.php. - Règles Apache .htaccess : les plugins de durcissement ajoutent des règles qui semblent correctes jusqu’à ce qu’elles ne le soient plus.
- Permissions/ownership de fichiers bizarres : il existe mais n’est pas lisible par l’utilisateur web.
Layer 4: Modules de sécurité (mod_security, OWASP CRS, Imunify, etc.)
- Une règle se déclenche sur le corps de la requête ou la query string.
- Le score d’anomalie atteint le seuil après qu’un plugin a changé la forme de la charge.
- Faux positifs sur des données sérialisées, des blobs base64 ou du JSON.
Layer 5: WordPress core et plugins
- Pas de handler enregistré pour
action; WordPress retourne0(pas 403, mais souvent interprété comme « bloqué »). - Échec du check_ajax_referer et le handler meurt avec 403.
- Échec du contrôle de capacité (
current_user_can) et le plugin refuse l’accès. - Plugins bloquant intentionnellement le trafic de user agents « suspects » ou de referers manquants.
Layer 6: Runtime PHP et infrastructure
- Les timeouts se manifestent par un comportement client étrange, des tentatives répétées ou des corps partiels traités comme 400.
- Disque plein ou pénurie d’inodes peuvent casser les sessions/caches, provoquant indirectement des échecs d’authentification.
Tâches pratiques : commandes, sorties et ce qu’elles signifient
Vous avez besoin de preuves, pas d’impressions. Voici des tâches réelles que vous pouvez exécuter sur un hôte Linux typique avec Nginx/Apache, PHP-FPM et WordPress. Chaque tâche inclut : commande, sortie d’exemple, ce que la sortie signifie, et la décision à prendre.
Task 1: Reproduire l’échec avec curl (baseline)
cr0x@server:~$ curl -i -s -X POST https://example.com/wp-admin/admin-ajax.php -d 'action=heartbeat'
HTTP/2 403
date: Sat, 27 Dec 2025 10:12:11 GMT
content-type: text/html; charset=UTF-8
server: cloudflare
cf-ray: 88f0abc1234abcd-LHR
<html>...Access denied...</html>
Sens : l’en-tête server: cloudflare et cf-ray crient « edge/WAF généré ». WordPress n’a jamais vu cette requête.
Décision : Arrêtez de déboguer WordPress. Allez dans les logs/règles du CDN/WAF. Essayez aussi de bypasser l’edge pour atteindre l’origine.
Task 2: Bypasser le CDN/edge et frapper l’origine directement (isoler)
cr0x@server:~$ curl -i -s --resolve example.com:443:203.0.113.10 https://example.com/wp-admin/admin-ajax.php -d 'action=heartbeat'
HTTP/2 200
date: Sat, 27 Dec 2025 10:12:44 GMT
content-type: text/html; charset=UTF-8
server: nginx
content-length: 1
0
Sens : L’origine renvoie 200 avec le corps 0. C’est le « pas de sortie » par défaut de WordPress pour une action AJAX non gérée, ou il attend une auth/nonces.
Décision : Si l’edge échoue mais que l’origine fonctionne, corrigez la règle WAF/limite de débit/protection bot pour admin-ajax. Si l’action devrait exister, vérifiez qu’elle est enregistrée.
Task 3: Inspecter les logs d’accès Nginx pour le statut et le comportement upstream
cr0x@server:~$ sudo tail -n 20 /var/log/nginx/access.log
198.51.100.24 - - [27/Dec/2025:10:12:44 +0000] "POST /wp-admin/admin-ajax.php HTTP/2.0" 200 1 "-" "curl/7.88.1"
198.51.100.24 - - [27/Dec/2025:10:13:02 +0000] "POST /wp-admin/admin-ajax.php HTTP/2.0" 403 153 "-" "Mozilla/5.0 ..."
Sens : L’origine renvoie parfois 403 aussi. Cela signifie que ce n’est pas « seulement Cloudflare ». Il y a probablement une règle serveur web, mod_security, ou un refus côté WordPress pour les requêtes de type navigateur.
Décision : Corréler avec les logs d’erreur et les logs du module de sécurité par timestamp.
Task 4: Vérifier le log d’erreur Nginx autour de l’événement
cr0x@server:~$ sudo grep -n "admin-ajax.php" /var/log/nginx/error.log | tail -n 5
41288#41288: *910 access forbidden by rule, client: 198.51.100.24, server: example.com, request: "POST /wp-admin/admin-ajax.php HTTP/2.0", host: "example.com"
Sens : « access forbidden by rule » est un classique de la config Nginx deny/allow, pas PHP.
Décision : Trouvez le bloc location correspondant et corrigez la priorité pour permettre admin-ajax.php (ou autoriser les POSTs vers celui-ci).
Task 5: Dumper la config Nginx effective et localiser les règles deny
cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -nE "location|deny all|wp-admin|admin-ajax\.php" | head -n 40
1123: location ^~ /wp-admin/ { deny all; }
1158: location = /wp-admin/admin-ajax.php { include fastcgi_params; fastcgi_pass unix:/run/php/php8.2-fpm.sock; }
Sens : Vous avez un deny large pour /wp-admin/ et un allow spécifique pour admin-ajax. Ça peut fonctionner — mais seulement si le location d’égalité est bien atteint.
Décision : Vérifiez l’ordre des locations et les modificateurs. location = devrait gagner sur les préfixes, mais d’autres règles (redir internes) peuvent encore poser problème. Testez et simplifiez.
Task 6: Valider si Apache (.htaccess) est impliqué (commun sur les stacks partagés)
cr0x@server:~$ sudo apachectl -M 2>/dev/null | grep -E "rewrite|security2"
rewrite_module (shared)
security2_module (shared)
Sens : Apache a mod_rewrite et mod_security activés. Même si vous êtes derrière un proxy, Apache peut quand même appliquer ces règles à l’origine.
Décision : Vérifiez les logs d’audit mod_security et les blocs .htaccess de durcissement pour les chemins wp-admin.
Task 7: Vérifier le log d’audit mod_security pour un hit de règle
cr0x@server:~$ sudo grep -n "admin-ajax.php" /var/log/modsec_audit.log | tail -n 8
--e3f2b9c7-H--
Message: Access denied with code 403 (phase 2). Matched phrase "select" at ARGS:query. [id "942100"] [msg "SQL Injection Attack Detected"] [severity "CRITICAL"]
Apache-Handler: proxy:unix:/run/php/php8.2-fpm.sock|fcgi://localhost/var/www/html/wp-admin/admin-ajax.php
Sens : OWASP CRS pense que votre requête contient des motifs SQLi. Le paramètre query le déclenche — il peut s’agir d’un terme de recherche, d’un filtre ou d’une charge plugin.
Décision : Ne désactivez pas mod_security globalement. Créez une exclusion étroite pour cet ID de règle sur cette action/paramètre spécifique, ou changez le plugin pour encoder/renommer les champs.
Task 8: Confirmer que WordPress voit la requête du tout (logs PHP-FPM ou slow logs)
cr0x@server:~$ sudo tail -n 20 /var/log/php8.2-fpm.log
[27-Dec-2025 10:13:02] WARNING: [pool www] child 18223 said into stderr: "Primary script unknown"
Sens : « Primary script unknown » est généralement SCRIPT_FILENAME mal configuré ou un mauvais mapping try_files/fastcgi_param. Cela produit souvent des 404/403/400 bizarres selon le comportement du serveur.
Décision : Corrigez les fastcgi params pour que /wp-admin/admin-ajax.php mappe au chemin filesystem réel.
Task 9: Valider que le fichier existe et que les permissions sont correctes
cr0x@server:~$ sudo ls -l /var/www/html/wp-admin/admin-ajax.php
-rw-r--r-- 1 www-data www-data 4496 Nov 8 12:10 /var/www/html/wp-admin/admin-ajax.php
Sens : Le fichier existe et est lisible. Les permissions ne sont pas en cause.
Décision : Montez dans la pile : règles de config, WAF, mod_security, authentification WordPress.
Task 10: Vérifier si l’IP client est bloquée localement (fail2ban / firewall)
cr0x@server:~$ sudo fail2ban-client status
Status
|- Number of jail: 2
`- Jail list: sshd, nginx-http-auth
cr0x@server:~$ sudo fail2ban-client status nginx-http-auth
Status for the jail: nginx-http-auth
|- Filter
| |- Currently failed: 2
| `- Total failed: 18
`- Actions
|- Currently banned: 1
`- Banned IP list: 198.51.100.24
Sens : L’IP du navigateur est bannie. Cela peut produire des 403 au serveur avant que WordPress ne s’exécute.
Décision : Debannez si c’est un faux positif, et ajustez la jail. Confirmez aussi que l’IP client réelle n’est pas remplacée par celle d’un proxy.
Task 11: Debannir une IP (prudemment) et retester
cr0x@server:~$ sudo fail2ban-client set nginx-http-auth unbanip 198.51.100.24
1
Sens : « 1 » indique succès (une IP débannie).
Décision : Retestez la requête AJAX ; si elle réussit, vous avez confirmé le bloqueur. Ensuite ajustez les filtres pour que les rafales normales d’admin-ajax ne ressemblent pas à du credential stuffing.
Task 12: Vérifier que WordPress renvoie 403 à cause d’un échec de nonce
cr0x@server:~$ sudo -u www-data wp option get home --path=/var/www/html
https://example.com
cr0x@server:~$ sudo -u www-data wp option get siteurl --path=/var/www/html
https://example.com
Sens : Home et siteurl sont corrects. Si ces valeurs sont erronées (http vs https, ancien domaine), WordPress peut générer des URLs AJAX ou des nonces qui ne correspondent pas à l’environnement de la requête.
Décision : Si les valeurs ne correspondent pas à la réalité, corrigez-les. Puis videz les caches et retestez.
Task 13: Activer brièvement le logging WordPress et le lire
cr0x@server:~$ sudo -u www-data bash -lc "grep -n \"WP_DEBUG\" /var/www/html/wp-config.php | head"
90:define('WP_DEBUG', false);
91:define('WP_DEBUG_LOG', false);
cr0x@server:~$ sudo -u www-data bash -lc "sed -i \"90,95{s/false/true/}\" /var/www/html/wp-config.php"
cr0x@server:~$ sudo -u www-data tail -n 30 /var/www/html/wp-content/debug.log
[27-Dec-2025 10:16:09 UTC] PHP Notice: check_ajax_referer failed for action=save_widget in /var/www/html/wp-content/plugins/example/plugin.php on line 211
Sens : Le plugin échoue la vérification du nonce. C’est un rejet côté WordPress, pas un blocage serveur/WAF.
Décision : Corrigez la mise en cache des pages qui incorporent des nonces, assurez-vous des cookies et des réglages same-site corrects, et confirmez que la requête AJAX inclut le champ nonce attendu par le plugin.
Task 14: Confirmer si la réponse provient de WordPress ou d’une couche de sécurité (en-têtes et corps)
cr0x@server:~$ curl -i -s https://example.com/wp-admin/admin-ajax.php?action=does_not_exist | head -n 20
HTTP/2 200
date: Sat, 27 Dec 2025 10:17:01 GMT
content-type: text/html; charset=UTF-8
server: nginx
x-powered-by: PHP/8.2.10
0
Sens : WordPress répond 0 pour des actions non gérées. C’est normal-ish, et cela vous indique que la requête a atteint WordPress avec succès.
Décision : Si votre requête en échec renvoie une page HTML brandée par le WAF, c’est un mode d’échec différent d’un 0 WordPress ou d’une erreur JSON.
Task 15: Vérifier les limites de taille du corps client dans Nginx (déclencheurs 400)
cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -n "client_max_body_size" | head
210: client_max_body_size 1m;
Sens : 1 Mo peut être trop petit pour certaines charges de plugin (saves de page builder, métadonnées média, options sérialisées volumineuses). Le dépassement peut apparaître comme 413, mais selon proxies/WAF ça peut dégénérer en 400.
Décision : Augmentez la limite pour le site (ou spécifiquement pour wp-admin) si justifié, et alignez les limites du WAF.
Task 16: Valider CORS et le traitement d’Origin (AJAX cross-domain)
cr0x@server:~$ curl -i -s -X OPTIONS https://example.com/wp-admin/admin-ajax.php \
-H 'Origin: https://shop.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
HTTP/2 403
server: nginx
content-type: text/html
Sens : OPTIONS est refusé. Si vous faites des requêtes cross-origin, votre serveur doit répondre correctement aux préflights.
Décision : Évitez les appels cross-origin à admin-ajax (meilleur choix), ou autorisez explicitement OPTIONS et définissez les en-têtes CORS corrects pour les origin(es) exact(es).
Trois mini-récits d’entreprise tirés de la production
Mini-récit 1 : L’incident causé par une mauvaise hypothèse
Une équipe marketing a déployé un plugin popup « spin-to-win » sur un site WordPress à fort trafic. C’était kitsch, mais ça convertissait. Le plugin utilisait admin-ajax.php pour tout : création de session, validation de coupons, et télémétrie « journaliser cette impression ». Le dev qui a validé pensait qu’admin-ajax était « interne » parce qu’il vivait sous /wp-admin/.
La sécurité a fait ce que fait la sécurité : ils ont déployé une nouvelle politique WAF qui renforçait l’accès aux endpoints admin, surtout tout ce qui est sous /wp-admin/ sans session authentifiée. Il y avait une allowlist pour les pages /wp-admin/ utilisées par de vrais admins, mais personne n’avait pensé à inclure admin-ajax parce que « ce n’est pas une page ».
À midi, les conversions se sont effondrées. Le popup apparaissait toujours, mais chaque « spin » renvoyait une requête morte. DevTools montrait du 403. Les tickets de support sont arrivés avec le ton familier du commerce moderne : « votre site est cassé et je suis fâché ».
La correction a pris dix minutes une fois que les bonnes personnes ont arrêté de débattre et ont commencé à tester. La règle WAF a été mise à jour pour autoriser les POSTs vers /wp-admin/admin-ajax.php pour des actions spécifiques utilisées par le plugin. Ils ont aussi limité le débit correctement et bloqué le reste. Puis ils ont créé une petite page d’inventaire « endpoints AJAX » dans le runbook, parce qu’il s’est avéré que la moitié de l’interactivité du site dépendait d’admin-ajax.
La leçon n’était pas « les WAF sont mauvais ». La leçon était que la nomenclature des chemins dans WordPress est trompeuse, et que les hypothèses rendent les incidents personnels.
Mini-récit 2 : L’optimisation qui s’est retournée contre eux
Une équipe plateforme voulait réduire la charge PHP. Raisonnable. Ils ont remarqué qu’admin-ajax.php représentait une grosse part des requêtes et ont décidé de le mettre en cache à l’edge pour « les utilisateurs anonymes » parce que « la plupart de ces appels sont identiques ». Ils ont ajouté une règle CDN : mettre en cache /wp-admin/admin-ajax.php avec un TTL court pour les requêtes sans cookies de connexion.
Ça a fonctionné en staging. Ça a aussi « fonctionné » en production juste assez longtemps pour convaincre tout le monde que c’était un gain. Puis la bizarrerie a commencé : certains utilisateurs recevaient des réponses périmées à des actions censées être uniques par visiteur. Certains voyaient l’échec de validations de coupons des autres. Le cas le plus amusant : un plugin retournait un nonce dans une réponse AJAX, et le CDN le servait joyeusement à tout le monde pendant 30 secondes.
Quand quelqu’un a admis que mettre en cache admin-ajax était risqué, le symptôme était un tas de rapports de bugs sans lien : échecs de panier, 403 aléatoires, « le bouton ne fait rien », et quelques problèmes de sécurité signalés par des clients attentifs. Personne ne pouvait reproduire de façon fiable parce que les réponses en cache dépendaient du POP edge et du moment.
Le rollback a été immédiat. Le suivi a été moins spectaculaire mais plus important : ils ont remplacé les interactions publiques à fort volume par des endpoints REST conçus pour être cacheables où approprié, et ils ont arrêté d’essayer de tromper WordPress en mettant en cache un endpoint fourre-tout.
Blague n°2 : mettre en cache admin-ajax, c’est comme photocopier votre badge d’accès et le distribuer pour « l’efficacité ». Ça réduit la friction.
Mini-récit 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Une grande entreprise gérait plusieurs propriétés WordPress derrière le même WAF, le même baseline Nginx et un jeu de règles mod_security partagé. Ils avaient déjà été brûlés par des faux positifs. Ils ont donc fait quelque chose de peu sexy : chaque exception WAF/mod_security devait être liée à (1) un endpoint spécifique, (2) un paramètre spécifique, et (3) un ticket avec une commande de reproduction.
Un après-midi, les requêtes admin-ajax ont commencé à échouer avec 400 pour une nouvelle fonctionnalité de page builder. Les éditeurs ne pouvaient plus enregistrer les mises en page. Pas de drama, juste de l’argent qui fuit tranquillement. L’équipe on-call a exécuté la commande curl de reproduction stockée dans le système de tickets (ils les gardaient dans des notes). Elle a échoué de la même manière depuis une IP propre. Parfait — on ne devine plus.
Ils ont vérifié les logs d’audit mod_security et on a trouvé une seule règle CRS se déclenchant sur un champ JSON dont le format avait récemment changé. Parce qu’ils avaient la politique « exceptions strictes seulement », ils n’ont pas désactivé tout le groupe de règles. Ils ont écrit une exclusion limitée à cette action et à ce champ, avec des commentaires.
L’incident a été court. La posture de sécurité est restée intacte. Le postmortem a été bref parce que les preuves étaient déjà capturées. L’ennuyeux a gagné. Encore.
Erreurs courantes : symptômes → cause racine → correction
Voici celles que je vois régulièrement, y compris celles qui commencent par « nous n’avons rien changé ». Quelqu’un a changé quelque chose. Ou quelque chose s’est mis à jour. Ou un cache a expiré.
1) Symptom : 403 avec une page de blocage brandée
Cause racine : Protection bot CDN/WAF, limitation de débit, ou règles WordPress gérées bloquant /wp-admin/admin-ajax.php.
Correction : Créez une règle d’autorisation pour admin-ajax avec des contraintes : n’autorisez que les méthodes HTTP requises, seulement les actions nécessaires (si votre WAF peut inspecter le corps/query), et limitez le débit plutôt que bloquer. Si des challenges bot sont activés, excluez admin-ajax des challenges.
2) Symptom : 403 uniquement pour les utilisateurs connectés, anonyme fonctionne
Cause racine : Problèmes de cookie ou SameSite après changement HTTPS/proxy ; les actions AJAX authentifiées nécessitent les bons cookies. Parfois le navigateur cesse d’envoyer les cookies à cause de SameSite ou d’un mismatch de domaine.
Correction : Assurez un schéma et un domaine cohérents pour home/siteurl. Confirmez que les cookies sont définis pour le bon domaine, et que les proxies transmettent correctement X-Forwarded-Proto afin que WordPress sache qu’il est en HTTPS.
3) Symptom : 403 après un changement « durcir wp-admin »
Cause racine : Règle Nginx/Apache qui nie tout /wp-admin/ et a oublié d’exempter admin-ajax.php et admin-post.php.
Correction : Ajoutez une exception spécifique pour = /wp-admin/admin-ajax.php (et testez la priorité des locations). Si vous restreignez wp-admin par IP, assurez-vous que admin-ajax n’est pas accidentellement restreint pour des actions publiques sur lesquelles vous comptez.
4) Symptom : 400 sans corps de réponse utile
Cause racine : Corps de requête rejeté par mod_security ou limites de taille (en-têtes/corps). Parfois un proxy tronque le corps, causant une « requête malformée » en aval.
Correction : Consultez d’abord le log d’audit mod_security. Ensuite vérifiez les limites Nginx/Apache et celles des reverse proxies. Alignez les limites edge → LB → origine. Ne « montez pas tout à l’infini » ; fixez un plafond réaliste.
5) Symptom : 200 OK mais le corps de réponse est « 0 »
Cause racine : L’action demandée n’est pas enregistrée, ou le handler est sorti tôt. Parfois vous frappez le mauvais site après une migration (mauvais vhost), et WordPress répond mais pas votre plugin.
Correction : Confirmez que le JS utilise la bonne URL AJAX. Confirmez que le plugin enregistre les hooks wp_ajax_. Vérifiez que vous n’êtes pas bloqué par des must-use plugins ou des chargements conditionnels spécifiques à l’environnement.
6) Symptom : 403 uniquement sur certains paramètres (requêtes de recherche, filtres)
Cause racine : Faux positifs WAF/mod_security sur le contenu de la charge (patterns SQLi/XSS) ou blobs base64/sérialisés.
Correction : Exception étroite : ID de règle + endpoint + paramètre. Ou changez le plugin pour envoyer du JSON et sanitize/encoder les champs de façon plus prévisible.
7) Symptom : 403 intermittent, souvent « marche après rafraîchissement »
Cause racine : Page mise en cache contenant des nonces expirés ou non correspondants ; la requête de l’utilisateur utilise un nonce que WordPress rejette.
Correction : Excluez les pages contenant des nonces spécifiques à l’utilisateur du cache complet, ou utilisez ESI/fragment caching. Vérifiez aussi la synchronisation horaire sur les serveurs ; les fenêtres de nonce dépendent d’un temps cohérent.
8) Symptom : admin-ajax échoue uniquement depuis des plages IP de bureau/VPN
Cause racine : Le NAT corporate est sur une liste de menaces, ou la limitation de débit est déclenchée par de nombreux utilisateurs derrière une même IP.
Correction : Ajustez la limitation de débit du WAF par session/cookie si possible, ou allowliste les plages IP corporate avec surveillance et contrôles compensatoires.
Listes de contrôle / plan étape par étape
Étape par étape : réparer admin-ajax.php 400/403 sans aggraver la situation
- Capturez une requête en échec depuis DevTools (méthode, en-têtes, payload, en-têtes/corps de réponse).
- Reproduisez avec curl en utilisant la même méthode et les mêmes paramètres. Si vous n’y arrivez pas, il vous manque des cookies ou un nonce.
- Décidez la couche :
- Présence d’en-têtes WAF/CDN ? Commencez là.
- Nginx « access forbidden by rule » ? Commencez par la config.
- Hit dans le log mod_security ? Partir de l’ID de règle et du paramètre.
- Debug WordPress montre un échec de nonce/capacité ? Corrigez l’app/caching/auth.
- Prouvez le comportement de l’origine en contournant le CDN et en retestant (pattern Task 2).
- Vérifiez les bannissements locaux (fail2ban/firewall). Ne perdez pas une heure à déboguer une IP bannie.
- Appliquez le plus petit changement sûr :
- Privilégiez l’allowlist d’une action/paramètre spécifique plutôt que d’autoriser tout admin-ajax.
- Privilégiez la limitation de débit plutôt que les blocs globaux.
- Privilégiez les endpoints REST WordPress pour le trafic public de type API.
- Retestez depuis plusieurs réseaux (bureau, hotspot mobile, serveur) pour valider que la correction n’est pas spécifique à une classe d’IP.
- Ajoutez du monitoring : suivez les taux 4xx d’admin-ajax à l’edge et à l’origine séparément. Une métrique mélangée cache le coupable.
- Écrivez la note de runbook : ce qui l’a bloqué, quel log l’a prouvé, quelle modification l’a corrigé, et ce qui aurait permis de le détecter plus tôt.
Checklist opérationnelle : empêcher admin-ajax de devenir votre usine de panne cachée
- Inventoriez les actions AJAX à fort volume (surtout les
nopriv). - Ne mettez pas en cache
/wp-admin/admin-ajax.phpau niveau CDN ou proxy. - Assurez-vous que
/wp-admin/admin-ajax.phpest joignable même si wp-admin est restreint par IP (à moins que vous vouliez vraiment casser des fonctionnalités frontales). - Alignez les limites de corps/en-têtes entre edge, LB et origine.
- Maintenez la synchronisation horaire (NTP) stable sur tous les nœuds pour éviter des bizarreries de nonce.
- Pour WAF/mod_security : utilisez des exceptions serrées et tracez quels plugins/actions les nécessitent.
FAQ
Pourquoi admin-ajax.php renvoie-t-il 403 alors que le site se charge correctement ?
Parce que votre page HTML est cacheable et publique, mais admin-ajax est interactif et souvent en POST. Les règles WAF, restrictions IP, mod_security ou vérifications nonce/cookie peuvent le bloquer sans affecter le rendu normal des pages.
Est-il sûr d’allowlister /wp-admin/admin-ajax.php dans le WAF ?
Ça peut l’être, si vous le faites avec des contraintes. Allowlister le chemin sans conditions est une invitation à l’abus. Préférez : autoriser seulement les méthodes requises, appliquer des limites de débit, et si possible n’autoriser que des valeurs action spécifiques.
Pourquoi je vois « 0 » comme corps de réponse ?
C’est la sortie par défaut de WordPress quand une action AJAX n’est pas gérée ou se termine sans imprimer. Cela signifie souvent que la requête a atteint WordPress, mais que votre handler plugin n’a pas été exécuté (mauvaise action, plugin non chargé, ou attente d’auth non satisfaite).
Les plugins de cache peuvent-ils provoquer des 403 admin-ajax ?
Indirectement, oui. Ils peuvent mettre en cache des pages qui incorporent des nonces ou des valeurs dépendantes de la session, conduisant à des échecs de nonce qui se manifestent par des 403 lorsque le handler AJAX utilise check_ajax_referer. Ils peuvent aussi interférer avec les cookies ou la compression dans des cas limites.
Dois-je passer d’admin-ajax à l’API REST WordPress ?
Pour les interactions publiques à fort volume : oui, généralement. Les endpoints REST sont plus faciles à sécuriser, observer et mettre en cache correctement. Gardez admin-ajax pour les flows d’administration legacy à moins d’être prêt à refactorer.
Quelle est la façon la plus rapide de savoir si mod_security bloque admin-ajax ?
Regardez le log d’audit mod_security pour un hit de règle avec un timestamp correspondant à la requête en échec. Si vous voyez un ID de règle et un paramètre apparié, vous avez votre preuve.
Pourquoi ça échoue seulement sur certaines requêtes de recherche ou valeurs de filtre ?
Les règles de sécurité inspectent le contenu de la charge. Certaines entrées utilisateurs ressemblent à des attaques (ou correspondent par coïncidence aux signatures). Ajustez avec une exception étroite ou modifiez le format/encodage de la requête.
Pourquoi ça marche depuis mon réseau domestique mais pas depuis le bureau ?
Les bureaux NATent souvent de nombreux utilisateurs derrière une seule IP. La limitation de débit et la détection de bots peuvent pénaliser cette IP partagée. De plus, les proxies d’entreprise peuvent altérer les en-têtes, déclenchant des règles plus strictes.
Des mauvais réglages WordPress home/siteurl peuvent-ils causer des échecs admin-ajax ?
Oui. Si WordPress croit fonctionner sur un schéma ou domaine différent, il peut générer des URLs AJAX et des nonces qui ne correspondent pas au contexte du navigateur. Corrigez ces options et vérifiez les en-têtes proxy.
Prochaines étapes qui tiennent
admin-ajax.php 400/403 n’est pas mystérieux. C’est un problème de chaîne de garde : vous devez prouver quel composant a retourné le statut et quelle règle ou vérification s’est déclenchée. Une fois prouvé, les corrections sont simples — et vous pouvez les maintenir serrées au lieu d’ouvrir des failles dans votre posture de sécurité.
Faites ceci ensuite :
- Choisissez une requête en échec et reproduisez-la avec curl (en incluant les cookies si nécessaire).
- Contournez le CDN pour comparer comportement edge vs origine.
- Lisez les logs correspondants (WAF → serveur web → mod_security → PHP/WordPress) dans cet ordre.
- Appliquez la plus petite allowlist ou correction code/caching qui résout la cause racine.
- Ajoutez un unique panneau de tableau de bord : taux 4xx admin-ajax scindé edge vs origine. Vous voulez que le prochain incident soit ennuyeux.
Citation d’ingénierie : « L’espoir n’est pas une stratégie. » — Gen. H. Norman Schwarzkopf