La menace

Si vous avez déjà vu passer une requête étrange du type ?action=save_settings&data=… dans vos logs, vous avez probablement croisé le scénario le plus fréquent en 2026 sur WordPress : une action AJAX ou REST “pratique” qui finit par ouvrir une porte d’entrée.

Le problème vient rarement du cœur WordPress 6.9.4. Dans mon expérience, l’incident part presque toujours d’un snippet ajouté “vite fait” (functions.php, plugin de snippets, mu-plugin) ou d’un petit plugin maison : absence de nonce, contrôle de capacité oublié, ou validation d’entrée trop permissive.

Concrètement, si une action sensible (mise à jour d’options, écriture de fichiers, import de contenu, exécution de requêtes) est exposée sans garde-fous, un attaquant peut :

  • modifier des options (email admin, URL du site, réglages SEO, clés API) pour détourner le site ;
  • créer un compte administrateur via un endpoint mal protégé ;
  • injecter du contenu (spam, liens, scripts) qui dégrade SEO et réputation ;
  • déclencher une RCE indirecte (ex. écriture d’un fichier PHP via un import non filtré, ou chargement d’un “template” contrôlé) ;
  • exfiltrer des données (emails, commandes WooCommerce, formulaires) si l’endpoint retourne trop d’informations.

La fréquence réelle varie selon votre surface d’attaque (plugins, endpoints publics, formulaires). Les scans automatisés ciblent en priorité :

  • les routes REST personnalisées mal authentifiées ;
  • les actions wp_ajax_nopriv_* qui font plus que “lire” ;
  • les formulaires front qui écrivent en base sans nonce ;
  • les imports CSV/JSON “admin only” mais atteignables sans contrôle de capacité.

Le risque, en langage simple : vous pensez avoir créé un bouton “sauvegarder”, mais vous avez parfois créé un formulaire public de configuration accessible à n’importe qui, y compris un bot.

Résumé rapide

  • Nonce + capacité : une action qui modifie quoi que ce soit doit vérifier un nonce et une capacité (ex. manage_options).
  • REST > admin-ajax pour les nouveaux développements : auth claire, schéma d’arguments, réponses standardisées.
  • Sanitization à l’entrée, validation stricte, escaping à la sortie : trois étapes différentes, à appliquer au bon endroit.
  • Ne stockez jamais de HTML/JS “libre” en option si vous ne maîtrisez pas qui peut écrire (XSS stockée classique).
  • Durcissez le serveur : désactivez l’édition de fichiers, bloquez l’exécution PHP dans uploads, ajoutez des headers.
  • Auditez vos endpoints : cherchez wp_ajax_nopriv, register_rest_route et update_option dans votre code.

Code vulnérable — ce qu’il ne faut PAS faire

Exemple réaliste : un développeur ajoute un “sauvegardeur” d’options via admin-ajax.php pour piloter un bandeau promo (texte + URL) depuis le front. Ça marche… jusqu’au jour où quelqu’un découvre l’action et écrase vos réglages.

<?php
/**
 * Exemple VULNÉRABLE : ne copiez pas ça.
 * Problèmes :
 * - action accessible aux non-connectés (nopriv)
 * - pas de nonce
 * - pas de contrôle de capacité
 * - validation insuffisante
 */
add_action( 'wp_ajax_nopriv_bpcab_save_promo', 'bpcab_save_promo_vulnerable' );
add_action( 'wp_ajax_bpcab_save_promo', 'bpcab_save_promo_vulnerable' );

function bpcab_save_promo_vulnerable() {
	// Mauvaise idée : on fait confiance à $_POST
	$text = isset( $_POST['text'] ) ? $_POST['text'] : '';
	$url  = isset( $_POST['url'] ) ? $_POST['url'] : '';

	// Mauvaise idée : update_option sans garde-fous
	update_option( 'bpcab_promo_text', $text );
	update_option( 'bpcab_promo_url', $url );

	wp_send_json_success( array(
		'message' => 'OK',
	) );
}

Voici ce qui se passe en coulisses :

  • Le hook wp_ajax_nopriv_... rend l’action accessible sans être connecté.
  • Sans nonce, n’importe quel site peut déclencher la requête depuis le navigateur d’un visiteur (CSRF), et n’importe quel bot peut l’appeler directement.
  • Sans capability check, même un compte abonné (ou un compte compromis) pourrait modifier des réglages qui devraient rester admin-only.
  • Sans validation, vous ouvrez la porte à des valeurs inattendues, et souvent à une XSS stockée si vous réaffichez ensuite bpcab_promo_text sans escaping strict.

Piège que je vois souvent : le code a été copié dans functions.php, puis cassé par une virgule ou un point-virgule manquant. Résultat : écran blanc, et le site repasse en mode “dépannage” si vous avez l’option de récupération. Sur WordPress 6.9.4, le mode recovery aide, mais il ne remplace pas une validation en staging.

Code sécurisé — la bonne implémentation

Pour WordPress 6.9.4, je privilégie une route REST plutôt que admin-ajax.php dès qu’on touche à des réglages. Vous obtenez une structure d’arguments, des réponses cohérentes, et une authentification plus propre.

Objectif : même fonctionnalité (sauvegarder un texte + URL), mais avec :

  • authentification (utilisateur connecté) ;
  • autorisation (capacité) ;
  • anti-CSRF (nonce REST) ;
  • validation (types, longueurs, URL) ;
  • sanitization (nettoyage) ;
  • réponses d’erreur exploitables.

1) Enregistrer la route REST avec permission_callback

<?php
/**
 * Implémentation sécurisée (WordPress 6.9.4+, PHP 8.1+).
 * À placer dans un plugin (recommandé) ou un mu-plugin.
 */

add_action( 'rest_api_init', function () {
	register_rest_route(
		'bpcab/v1',
		'/promo',
		array(
			array(
				'methods'             => 'POST',
				'callback'            => 'bpcab_rest_save_promo',
				'permission_callback' => 'bpcab_rest_can_manage_promo',
				'args'                => array(
					'text' => array(
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
						'validate_callback' => function ( $value ) {
							// Validation stricte : évite les payloads énormes et les surprises
							return is_string( $value ) && mb_strlen( $value ) >= 1 && mb_strlen( $value ) <= 140;
						},
					),
					'url'  => array(
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'esc_url_raw',
						'validate_callback' => function ( $value ) {
							// Autorise uniquement http(s)
							if ( ! is_string( $value ) ) {
								return false;
							}
							$scheme = wp_parse_url( $value, PHP_URL_SCHEME );
							return in_array( $scheme, array( 'http', 'https' ), true );
						},
					),
				),
			),
		)
	);
} );

function bpcab_rest_can_manage_promo( WP_REST_Request $request ) : bool {
	// 1) L'utilisateur doit être connecté
	if ( ! is_user_logged_in() ) {
		return false;
	}

	// 2) Capacité : adaptez selon votre besoin (manage_options est un classique)
	return current_user_can( 'manage_options' );
}

Explication simple : la route existe, mais WordPress refusera la requête si l’utilisateur n’est pas connecté ou n’a pas la bonne capacité.

Explication technique : permission_callback est évalué avant callback. C’est votre barrière principale côté application. Les args imposent un contrat : type, sanitization, validation. Ça réduit énormément les bugs et les contournements.

2) Callback : mise à jour d’options, avec gestion d’erreurs

<?php
function bpcab_rest_save_promo( WP_REST_Request $request ) : WP_REST_Response|WP_Error {
	$text = (string) $request->get_param( 'text' );
	$url  = (string) $request->get_param( 'url' );

	// Défense en profondeur : même si args valide, on reste prudent
	$text = sanitize_text_field( $text );
	$url  = esc_url_raw( $url );

	if ( '' === $text ) {
		return new WP_Error(
			'bpcab_invalid_text',
			'Le texte ne peut pas être vide.',
			array( 'status' => 400 )
		);
	}

	if ( empty( $url ) ) {
		return new WP_Error(
			'bpcab_invalid_url',
			'URL invalide.',
			array( 'status' => 400 )
		);
	}

	update_option( 'bpcab_promo_text', $text, false );
	update_option( 'bpcab_promo_url', $url, false );

	return new WP_REST_Response(
		array(
			'success' => true,
			'data'    => array(
				'text' => $text,
				'url'  => $url,
			),
		),
		200
	);
}

Détail qui compte : le troisième paramètre de update_option ($autoload) est fixé à false. J’ai vu des sites ralentir parce qu’un plugin stockait trop d’options en autoload. Sur des pages très visitées, ça se paie en TTFB.

3) Côté JavaScript : nonce REST et fetch

Vous n’avez pas besoin de publier d’exemple “exploitable”. Mais vous avez besoin d’un appel correct côté front/admin. Le nonce REST se récupère via wp_create_nonce( 'wp_rest' ) et s’envoie dans le header X-WP-Nonce.

<?php
/**
 * Enqueue : charge un script et lui passe l'URL REST + nonce.
 * À utiliser dans l'admin, ou sur le front si nécessaire.
 */
add_action( 'admin_enqueue_scripts', function ( $hook ) {
	// Exemple : ne charger que sur une page d'options (à adapter)
	if ( 'settings_page_bpcab-promo' !== $hook ) {
		return;
	}

	wp_enqueue_script(
		'bpcab-promo',
		plugins_url( 'assets/promo.js', __FILE__ ),
		array(),
		'1.0.0',
		true
	);

	wp_localize_script(
		'bpcab-promo',
		'BPCAB_PROMO',
		array(
			'restUrl' => esc_url_raw( rest_url( 'bpcab/v1/promo' ) ),
			'nonce'   => wp_create_nonce( 'wp_rest' ),
		)
	);
} );
async function savePromo(text, url) {
  const res = await fetch(BPCAB_PROMO.restUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-WP-Nonce': BPCAB_PROMO.nonce
    },
    body: JSON.stringify({ text, url })
  });

  const data = await res.json();
  if (!res.ok) {
    throw new Error(data?.message || 'Erreur lors de la sauvegarde');
  }
  return data;
}

Pièges fréquents :

  • Utiliser admin-ajax.php “par habitude” et oublier que nopriv expose l’action.
  • Mettre le code dans le mauvais fichier (ex. functions.php du thème parent) puis le perdre à la prochaine mise à jour.
  • Oublier de vider le cache (plugin de cache, cache serveur, Cloudflare). Vous testez un correctif… mais vous servez encore l’ancien JS.
  • Tester directement en production sans sauvegarde. Sur un site Elementor/Divi/Avada, un simple fatal error PHP peut aussi casser l’éditeur.

Compatibilité Divi 5, Elementor, Avada

Ce pattern REST fonctionne bien avec les page builders, parce qu’il ne dépend pas d’un rendu spécifique :

  • Divi 5 : utilisez un module personnalisé ou un “Code Module” pour l’UI, mais gardez la sauvegarde côté REST. Évitez de stocker du HTML libre en option si l’UI est accessible à des rôles non-admin.
  • Elementor : si vous créez un widget, stockez la configuration via les controls Elementor (qui passent par l’admin et des capacités), et utilisez REST seulement pour des actions dynamiques.
  • Avada : même logique avec Fusion Builder. Les actions “live” doivent rester admin-only et protégées par nonce.

Configuration serveur

Le code sécurisé réduit le risque, mais la majorité des compromissions que j’ai eu à nettoyer venaient d’une chaîne : plugin vulnérable + serveur permissif. Le durcissement serveur limite l’impact quand un morceau tombe.

.htaccess (Apache) : bloquer l’exécution PHP dans uploads

Si votre hébergeur utilise Apache et que .htaccess est actif, placez ceci dans wp-content/uploads/.htaccess. Le but : empêcher l’exécution de scripts PHP déposés dans uploads.

# Créez/éditez wp-content/uploads/.htaccess (Apache)
# Bloque l'exécution des fichiers PHP dans uploads
<FilesMatch ".(php|phtml|phar)$">
  Require all denied
</FilesMatch>

Edge case : certains plugins très anciens déposent des “helpers” en uploads (mauvaise pratique). Avec WordPress 6.9.4, évitez ce type de plugin. Si vous n’avez pas le choix, isolez-le et documentez l’exception.

.htaccess : protéger wp-config.php et fichiers sensibles

# Dans le .htaccess à la racine WordPress (Apache)
<Files wp-config.php>
  Require all denied
</Files>

<FilesMatch "^(readme.html|license.txt)$">
  Require all denied
</FilesMatch>

wp-config.php : désactiver l’éditeur de fichiers

Ça ne bloque pas un attaquant qui a déjà un accès admin complet, mais ça réduit les dégâts sur des scénarios où un rôle élevé est obtenu via une faiblesse.

<?php
// Désactive l'éditeur de fichiers dans l'admin (recommandé)
define( 'DISALLOW_FILE_EDIT', true );

// Optionnel : bloque aussi l'installation/MAJ via l'admin (à évaluer selon votre workflow)
// define( 'DISALLOW_FILE_MODS', true );

Headers HTTP (Apache) : réduire XSS/clickjacking

Ces headers n’empêchent pas une faille logique, mais ils limitent certains impacts. Testez toujours après ajout, surtout si vous avez des iframes légitimes (ex. intégrations).

# Dans .htaccess (si mod_headers est actif)
<IfModule mod_headers.c>
  Header always set X-Content-Type-Options "nosniff"
  Header always set Referrer-Policy "strict-origin-when-cross-origin"
  Header always set X-Frame-Options "SAMEORIGIN"
  Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
</IfModule>

HTTPS et cookies

Si votre site est 100% HTTPS (ce qui devrait être le cas en 2026), forcez SSL pour l’admin :

<?php
// Force SSL sur /wp-admin (si votre HTTPS est correctement configuré)
define( 'FORCE_SSL_ADMIN', true );

Vérifier si votre site est vulnérable

Je pars d’une règle simple : si vous avez du code custom, vous avez des endpoints. Et si vous avez des endpoints, vous devez les inventorier.

Audit rapide via WP-CLI : chercher les patterns dangereux

Sur un serveur avec WP-CLI, commencez par localiser les actions AJAX publiques et les mises à jour d’options.

# Rechercher des actions AJAX accessibles aux non-connectés
wp eval 'echo "Mu-plugins:n";' && grep -R --line-number "wp_ajax_nopriv_" wp-content/mu-plugins wp-content/plugins wp-content/themes 2>/dev/null

# Rechercher des endpoints REST custom
grep -R --line-number "register_rest_route" wp-content/mu-plugins wp-content/plugins wp-content/themes 2>/dev/null

# Rechercher des écritures sensibles
grep -R --line-number "update_option|add_option|set_transient|wp_insert_user|wp_update_user" wp-content/mu-plugins wp-content/plugins wp-content/themes 2>/dev/null

Ce que vous cherchez :

  • des callbacks d’écriture (update_option, wp_insert_user, écriture fichier) accessibles depuis wp_ajax_nopriv ;
  • des routes REST avec permission_callback = __return_true (je l’ai vu en production, plus d’une fois) ;
  • des validations faibles (ex. sanitize_text_field utilisé à la place d’une validation de format).

Vérifier les logs : signaux typiques

Dans les access logs (Nginx/Apache), surveillez :

  • pics sur /wp-admin/admin-ajax.php avec des paramètres d’action répétitifs ;
  • requêtes POST sur /wp-json/ vers des namespaces inattendus ;
  • codes 401/403 en rafale (tentatives d’accès) ;
  • user-agents vides ou incohérents.

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Options du site qui changent “toutes seules” Endpoint AJAX/REST non protégé, compte admin compromis Rechercher update_option + actions publiques ; vérifier les logs sur admin-ajax.php Ajouter nonce + capacité ; supprimer nopriv ; réinitialiser mots de passe et clés
Spam injecté dans des pages/articles XSS stockée via option/meta non filtrée, ou compte éditeur compromis Comparer révisions, inspecter contenu HTML ; scanner les options autoload Nettoyer contenu ; limiter HTML autorisé ; durcir rôles/capacités
Redirections vers un site externe Option siteurl/home modifiée, plugin malveillant, JS injecté Contrôler home/siteurl en base ; vérifier wp_options Restaurer valeurs ; supprimer backdoor ; ajouter contrôles et WAF
Charge CPU sur admin-ajax.php Endpoint public abusé (DoS applicatif) Top URLs (GoAccess), logs ; profiler requêtes Limiter actions publiques ; cache ; rate limit (serveur/WAF)

Erreurs de sécurité fréquentes

Erreur Risque Solution
Utiliser wp_ajax_nopriv_* pour une action d’écriture Modification non authentifiée, bots, CSRF Supprimer nopriv ou limiter à lecture ; exiger auth + nonce
Vérifier un nonce mais pas les capacités Un compte basique peut faire des actions admin current_user_can() systématique pour toute action sensible
Sanitization “générique” à la place d’une validation Données incohérentes, contournements, bugs logiques Valider format/longueur/liste blanche + sanitization adaptée
Stocker du HTML/JS en option et l’afficher sans escaping XSS stockée Éviter HTML libre ; sinon wp_kses() + esc_html()/esc_attr()
Copier un snippet d’un ancien tutoriel API obsolète, failles, incompatibilités WP 6.9.4/PHP 8.1 Vérifier sur developer.wordpress.org ; tester en staging
Mettre le code dans le thème parent Perte à la mise à jour, site cassé Plugin dédié ou mu-plugin ; thème enfant si nécessaire
Mauvais hook (ex. REST enregistré trop tôt ou trop tard) Route non disponible, contournements via fallback Utiliser rest_api_init pour REST, hooks dédiés pour admin
Oublier de vider le cache après correctif Vous croyez avoir corrigé, mais l’ancien JS tourne Purger cache plugin/CDN/navigateur ; versionner les assets
Tester sur production sans sauvegarde Interruption, perte de données Staging + sauvegarde + plan de rollback

Checklist de durcissement

  • Mettre à jour WordPress 6.9.4+, thèmes et plugins (et supprimer ceux qui ne servent plus).
  • PHP 8.1+ (idéalement 8.2/8.3 si votre stack le supporte) et extensions à jour.
  • Désactiver l’éditeur : DISALLOW_FILE_EDIT.
  • Bloquer PHP dans uploads (Apache/Nginx équivalent) et vérifier les permissions fichiers.
  • Forcer HTTPS + FORCE_SSL_ADMIN si applicable.
  • Inventorier endpoints : REST routes, AJAX actions, webhooks entrants.
  • Pour toute écriture : nonce + capacité + validation stricte + logs.
  • Limiter les rôles : pas d’admin partagé, comptes nominatifs, 2FA si possible.
  • Sauvegardes : quotidiennes, test de restauration, stockage hors site.
  • Journaliser : logs serveur + logs d’application pour actions sensibles.

Que faire si le site est déjà compromis ?

Si vous suspectez une compromission, le mauvais réflexe est de “juste changer le mot de passe admin”. Si une backdoor est en place, elle recréera un accès.

  1. Mettre le site en maintenance (ou au minimum bloquer l’admin) le temps de stabiliser. Si vous avez un CDN/WAF, activez un mode protection renforcée.
  2. Faire une sauvegarde forensic (fichiers + base) avant nettoyage. Vous en aurez besoin pour comprendre l’entrée et prouver ce qui a changé.
  3. Identifier le point d’entrée :
    • plugins récemment ajoutés/mis à jour ;
    • snippets dans functions.php / plugin de snippets ;
    • comptes admin inconnus ;
    • routes REST/AJAX custom sans permission_callback/nonce.
  4. Réinstaller WordPress core proprement (remplacer wp-admin et wp-includes depuis une source officielle), sans écraser wp-content pour le moment.
  5. Nettoyer wp-content :
    • supprimer thèmes/plugins inconnus ;
    • réinstaller depuis wordpress.org/plugins les plugins nécessaires ;
    • chercher fichiers PHP récents dans uploads.
  6. Auditer la base :
    • comptes admin ;
    • options suspectes (autoload énorme, scripts) ;
    • contenus injectés (articles, widgets, templates).
  7. Réinitialiser tous les secrets :
    • mots de passe (WP, FTP/SSH, DB, hébergeur) ;
    • clés de sécurité WordPress (AUTH_KEY, etc.) ;
    • tokens API (SMTP, CDN, paiement, reCAPTCHA).
  8. Mettre en place des contrôles : WAF, 2FA, limitation des tentatives, alertes de création d’admin.
  9. Vérifier la livraison : purge caches, scanner front (source HTML) pour scripts injectés, retester les parcours critiques (login, formulaires, checkout).

Astuce pratique : si vous avez WP-CLI, listez les admins et cherchez les comptes récents.

wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

Conseils de maintenance et compatibilité

Sur WordPress 6.9.4, le cœur fournit une base solide, mais votre sécurité dépend de votre discipline de développement.

Préférez un plugin (ou mu-plugin) pour le code de sécurité

Mettre vos endpoints REST/AJAX dans un thème (même enfant) est une dette. J’ai souvent vu une refonte Divi/Elementor casser un correctif de sécurité parce que le thème a changé. Un petit plugin “site” est plus stable et versionnable.

Performance : attention aux options autoload

Chaque option autoload est chargée sur la plupart des requêtes. Si vous stockez des payloads (JSON, HTML, listes), vous gonflez alloptions. Sur des sites Avada avec beaucoup d’options, ça peut devenir un goulot.

SEO : les compromissions laissent des traces

Après nettoyage, surveillez :

  • Search Console (pages spam, redirections) ;
  • sitemaps générés ;
  • liens sortants injectés ;
  • temps de réponse (malware = CPU).

Compatibilité future

Évitez les snippets qui dépendent d’implémentations internes. Restez sur les APIs documentées : REST API, Settings API, Options API. Quand vous migrez un ancien code AJAX vers REST, gardez une période de transition mais supprimez l’ancien endpoint dès que possible.


Ressources

FAQ

Est-ce que je dois abandonner admin-ajax.php ?

Non. Pour des actions simples et internes à l’admin, wp_ajax_* reste valide. Mais pour des endpoints structurés et maintenables, la REST API est généralement plus propre. Le point non négociable : nonce + capacité pour toute action d’écriture.

Un nonce WordPress suffit-il à sécuriser une action ?

Non. Un nonce limite surtout le CSRF. Il ne remplace pas un contrôle de capacité. Dans les incidents réels, j’ai vu des nonces correctement mis en place… mais accessibles à des rôles trop faibles via une page front.

Pourquoi valider la longueur du texte si je le sanitize déjà ?

Parce que la sanitization ne fixe pas vos règles métier et ne protège pas des payloads énormes. Une validation de longueur évite des abus (stockage, logs, perf) et des comportements imprévus.

Où placer ce code : functions.php, plugin de snippets, plugin custom ?

Pour de la sécurité, évitez functions.php du thème parent. Un plugin custom (ou mu-plugin) est plus fiable, versionnable, et ne disparaît pas lors d’un changement de thème (Divi/Elementor/Avada).

Je reçois des 403 sur la route REST après ajout du nonce. Normal ?

Souvent oui : nonce absent/expiré, cookies non envoyés, ou appel cross-domain. Vérifiez que vous envoyez X-WP-Nonce, que l’utilisateur est bien connecté, et que l’appel cible le même domaine (attention aux environnements avec www/non-www).

Comment éviter qu’un cache casse mes tests de sécurité ?

Versionnez vos assets (paramètre de version dans wp_enqueue_script), purgez le cache plugin/CDN, et testez en navigation privée. J’ai souvent vu des correctifs “inefficaces” qui étaient juste du cache.

Est-ce que DISALLOW_FILE_EDIT bloque un pirate ?

Ça bloque une technique courante (édition de thème/plugin depuis l’admin). Si l’attaquant a déjà un accès FTP/SSH ou une RCE, ça ne suffit pas. Mais en défense en profondeur, c’est un réglage simple et utile.

Comment repérer rapidement une XSS stockée via options ?

Surveillez les options autoload volumineuses, et cherchez des fragments <script, onerror=, javascript: dans la base. Ensuite, corrigez à la source : qui peut écrire ces options, et comment sont-elles affichées (escaping) ?

Quel est le minimum vital pour un endpoint qui modifie la base ?

Authentification (utilisateur connecté), autorisation (capabilité), nonce (anti-CSRF), validation stricte des paramètres, et journalisation des actions sensibles (au moins en debug/staging).

Mon plugin tiers expose une route REST publique. Dois-je paniquer ?

Pas forcément. Une route publique en lecture peut être normale. Ce qui est dangereux, c’est une route publique qui écrit ou qui expose des données sensibles. Auditez permission_callback et les méthodes autorisées (POST/PUT/DELETE).