Si vous avez déjà vu dans vos logs une action “admin” exécutée alors que vous n’étiez pas connecté, il y a de bonnes chances qu’un formulaire (ou un lien d’action) n’ait pas été protégé contre le CSRF.

La menace

Une attaque CSRF (Cross-Site Request Forgery) consiste à faire exécuter une action à un utilisateur déjà authentifié, sans qu’il s’en rende compte. L’attaquant ne “devine” pas votre mot de passe. Il profite du fait que votre navigateur envoie automatiquement les cookies de session à WordPress quand une requête cible votre domaine.

Concrètement, si vous avez un formulaire d’admin (ou une action AJAX) qui met à jour une option, crée un compte, change un e-mail, publie un contenu, ou déclenche une exportation, un CSRF peut forcer l’exécution de cette action via une simple visite sur une page piégée. J’ai souvent croisé ce problème sur des sites où un développeur a ajouté une page d’options “vite fait” dans functions.php, ou un snippet dans un plugin de snippets, en oubliant le nonce.

Ce que l’attaquant peut faire dépend de l’action non protégée :

  • Modifier des réglages (URL du site, e-mail admin, options SEO, options de cache).
  • Créer/modifier du contenu si vous avez une action côté front qui insère des posts.
  • Ajouter un utilisateur si une route admin-post ou AJAX le permet (souvent combiné à une mauvaise vérification de capacités).
  • Déclencher des actions coûteuses (imports, exports, purge cache) pour dégrader les performances.

La fréquence réelle est difficile à chiffrer globalement, parce que le CSRF est souvent un “maillon” dans une chaîne (ex. CSRF + compte admin déjà connecté). En revanche, côté écosystème WordPress, c’est un motif récurrent dans les correctifs de sécurité de plugins : “Missing nonce check” ou “CSRF vulnerability” revient constamment dans les changelogs. Vous le verrez aussi dans des tickets et patches sur Trac quand une action sensible n’a pas de vérification adéquate (source de référence : core.trac.wordpress.org).

En langage simple : si une action a un impact, elle doit exiger une preuve que la requête vient bien d’un écran légitime. Dans WordPress, cette preuve s’appelle un nonce (un “token” à usage limité, lié à une action).

Résumé rapide

  • Nonce ≠ sécurité absolue : c’est une protection anti-CSRF, pas un contrôle d’accès. Il faut aussi vérifier les capacités.
  • Protégez toutes les actions sensibles : pages d’options, admin-post.php, admin-ajax.php, endpoints REST personnalisés.
  • Utilisez wp_nonce_field() dans les formulaires et check_admin_referer() ou check_ajax_referer() à la réception.
  • Pour REST API, privilégiez le header X-WP-Nonce et wp_create_nonce( ‘wp_rest’ ) côté client, plus permission_callback côté serveur.
  • Évitez les anti-patterns : nonce en GET sur une action destructrice, absence de current_user_can(), ou vérifications dans le mauvais hook.
  • Sur WordPress 6.9.4 + PHP 8.1, gardez le code compatible : échappement, validation stricte, réponses JSON propres, et hooks chargés au bon moment.

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

Exemple réaliste : une petite page d’options “Marketing” qui permet de mettre à jour un pixel. Le développeur a protégé l’accès à la page, mais pas la soumission. Résultat : un admin connecté qui visite une page piégée peut mettre à jour l’option à son insu.

<?php
/**
 * EXEMPLE VULNÉRABLE (NE PAS UTILISER)
 * WordPress 6.9.4+ / PHP 8.1+
 */

add_action( 'admin_menu', function () {
	add_options_page(
		'Marketing',
		'Marketing',
		'manage_options',
		'mon-marketing',
		'mon_marketing_page_vulnerable'
	);
} );

function mon_marketing_page_vulnerable() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	// Soumission sans nonce : vulnérable au CSRF.
	if ( isset( $_POST['pixel_id'] ) ) {
		// Mauvaise pratique : pas de sanitization adaptée, pas de validation, pas de nonce.
		update_option( 'mon_pixel_id', $_POST['pixel_id'] );
		echo '<div class="updated"><p>Option enregistrée.</p></div>';
	}

	$pixel_id = get_option( 'mon_pixel_id', '' );
	?>
	<div class="wrap">
		<h2>Réglages Marketing</h2>
		<form method="post">
			<label for="pixel_id">Pixel ID</label><br>
			<input type="text" id="pixel_id" name="pixel_id" value="<?php echo esc_attr( $pixel_id ); ?>">
			<p><button class="button button-primary" type="submit">Enregistrer</button></p>
		</form>
	</div>
	<?php
}

Ce qui se passe en coulisses :

  • L’admin est connecté à votre site (cookie de session valide).
  • Il visite un site tiers qui soumet un formulaire caché vers votre URL d’options (ou une URL admin-post/admin-ajax).
  • Le navigateur envoie le cookie WordPress automatiquement.
  • Votre code voit $_POST['pixel_id'] et exécute update_option().

Ce que je vois souvent comme “fausse sécurité” : “j’ai mis current_user_can(), donc c’est bon”. Non. CSRF contourne l’intention de l’utilisateur ; l’utilisateur a bien les droits, il ne fait juste pas l’action volontairement.

Autres variantes vulnérables courantes :

  • Action destructrice via lien en GET : wp-admin/admin.php?page=mon-plugin&delete=1 sans nonce.
  • AJAX admin (admin-ajax.php) sans check_ajax_referer().
  • Endpoint REST custom avec permission_callback => '__return_true' (je l’ai vu en production…), sans autre garde-fou.

Code sécurisé — la bonne implémentation

On garde la même fonctionnalité, mais on ajoute les protections attendues en 2026 sur WordPress 6.9.4 :

  • Nonce : empêche le CSRF en exigeant un jeton valide lié à l’action.
  • Vérification de capacité : limite l’action aux rôles autorisés.
  • Sanitization + validation : évite les données inattendues (et réduit l’impact si un autre bug existe).
  • Post/Redirect/Get : évite les doubles soumissions et rend le flux plus propre.

Version sécurisée avec traitement sur la même page

<?php
/**
 * EXEMPLE SÉCURISÉ
 * WordPress 6.9.4+ / PHP 8.1+
 */

add_action( 'admin_menu', function () {
	add_options_page(
		'Marketing',
		'Marketing',
		'manage_options',
		'mon-marketing',
		'mon_marketing_page_secure'
	);
} );

function mon_marketing_page_secure() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	// Traitement de la soumission.
	if ( isset( $_POST['mon_marketing_submit'] ) ) {
		/*
		 * Protection CSRF :
		 * - wp_nonce_field() aura envoyé un champ caché "_wpnonce"
		 * - check_admin_referer() valide le nonce et stoppe si invalide
		 */
		check_admin_referer( 'mon_marketing_save', 'mon_marketing_nonce' );

		// Sanitization adaptée (ex : ID alphanum + tirets/underscores).
		$pixel_id = isset( $_POST['pixel_id'] ) ? sanitize_text_field( wp_unslash( $_POST['pixel_id'] ) ) : '';

		// Validation simple : vous pouvez durcir selon votre format réel.
		if ( $pixel_id !== '' && ! preg_match( '/^[A-Za-z0-9_-]{4,64}$/', $pixel_id ) ) {
			add_settings_error(
				'mon_marketing_messages',
				'mon_marketing_pixel_invalid',
				'Pixel ID invalide (caractères autorisés : lettres, chiffres, _ et -).',
				'error'
			);
		} else {
			update_option( 'mon_pixel_id', $pixel_id, false );

			add_settings_error(
				'mon_marketing_messages',
				'mon_marketing_saved',
				'Option enregistrée.',
				'updated'
			);

			// PRG : redirection pour éviter la resoumission au refresh.
			wp_safe_redirect(
				add_query_arg(
					array( 'page' => 'mon-marketing', 'settings-updated' => 'true' ),
					admin_url( 'options-general.php' )
				)
			);
			exit;
		}
	}

	$pixel_id = (string) get_option( 'mon_pixel_id', '' );
	?>

	<div class="wrap">
		<h2>Réglages Marketing</h2>

		<?php
		// Affiche les messages (erreurs ou succès).
		settings_errors( 'mon_marketing_messages' );
		?>

		<form method="post">
			<?php
			/*
			 * Ajoute le nonce dans le formulaire.
			 * Le nom du champ nonce est "mon_marketing_nonce".
			 */
			wp_nonce_field( 'mon_marketing_save', 'mon_marketing_nonce' );
			?>

			<label for="pixel_id">Pixel ID</label><br>
			<input type="text" id="pixel_id" name="pixel_id" value="<?php echo esc_attr( $pixel_id ); ?>">

			<p>
				<button class="button button-primary" type="submit" name="mon_marketing_submit" value="1">Enregistrer</button>
			</p>
		</form>
	</div>
	<?php
}

Explication “simple” :

  • wp_nonce_field() ajoute un champ caché unique au formulaire.
  • check_admin_referer() refuse la soumission si le champ est absent ou invalide.
  • Un site tiers ne peut pas deviner ce nonce, donc ne peut pas forcer la requête.

Explication “technique” :

  • Le nonce WordPress est lié à une action (ici mon_marketing_save) et à l’utilisateur/session. Il est valable sur une fenêtre de temps (WordPress utilise des “ticks”).
  • check_admin_referer( $action, $query_arg ) vérifie le nonce transmis dans $_REQUEST[$query_arg] et appelle wp_nonce_ays() en cas d’échec (ce qui stoppe le flux normal).
  • On fait aussi wp_unslash() avant sanitization, parce que WordPress slashe les superglobales. C’est un détail, mais c’est un détail qui évite des bugs pénibles sur certains caractères.

Variante recommandée : admin-post.php (plus propre pour des actions)

Quand l’action devient plus complexe, je préfère séparer l’affichage (page) et le traitement (handler). Ça évite les “if POST” au milieu du rendu, et c’est plus simple à auditer.

<?php
/**
 * Page + handler admin-post sécurisé.
 * WordPress 6.9.4+ / PHP 8.1+
 */

add_action( 'admin_menu', function () {
	add_options_page(
		'Marketing',
		'Marketing',
		'manage_options',
		'mon-marketing',
		'mon_marketing_page_admin_post'
	);
} );

function mon_marketing_page_admin_post() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	$pixel_id = (string) get_option( 'mon_pixel_id', '' );
	?>
	<div class="wrap">
		<h2>Réglages Marketing</h2>

		<?php settings_errors( 'mon_marketing_messages' ); ?>

		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
			<input type="hidden" name="action" value="mon_marketing_save">
			<?php wp_nonce_field( 'mon_marketing_save', 'mon_marketing_nonce' ); ?>

			<label for="pixel_id">Pixel ID</label><br>
			<input type="text" id="pixel_id" name="pixel_id" value="<?php echo esc_attr( $pixel_id ); ?>">

			<p><button class="button button-primary" type="submit">Enregistrer</button></p>
		</form>
	</div>
	<?php
}

add_action( 'admin_post_mon_marketing_save', 'mon_marketing_handle_save' );

function mon_marketing_handle_save() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( 'Accès refusé.' );
	}

	check_admin_referer( 'mon_marketing_save', 'mon_marketing_nonce' );

	$pixel_id = isset( $_POST['pixel_id'] ) ? sanitize_text_field( wp_unslash( $_POST['pixel_id'] ) ) : '';

	if ( $pixel_id !== '' && ! preg_match( '/^[A-Za-z0-9_-]{4,64}$/', $pixel_id ) ) {
		add_settings_error(
			'mon_marketing_messages',
			'mon_marketing_pixel_invalid',
			'Pixel ID invalide.',
			'error'
		);

		wp_safe_redirect(
			add_query_arg( array( 'page' => 'mon-marketing' ), admin_url( 'options-general.php' ) )
		);
		exit;
	}

	update_option( 'mon_pixel_id', $pixel_id, false );

	add_settings_error(
		'mon_marketing_messages',
		'mon_marketing_saved',
		'Option enregistrée.',
		'updated'
	);

	wp_safe_redirect(
		add_query_arg(
			array( 'page' => 'mon-marketing', 'settings-updated' => 'true' ),
			admin_url( 'options-general.php' )
		)
	);
	exit;
}

Pièges réels que je vois en audit :

  • Copier le code au mauvais endroit : un handler admin-post dans un fichier qui n’est jamais chargé (ex. un template). Mettez-le dans un plugin ou le functions.php du thème enfant, chargé partout.
  • Oublier une parenthèse et casser l’admin : testez d’abord sur staging, et gardez un accès FTP/SSH.
  • Hook inadapté : utiliser init pour traiter un POST admin au lieu de admin_post_*. Ça marche “parfois”, puis ça casse avec un plugin de sécurité.
  • Conflit cache : si vous testez côté front, un cache agressif peut servir un HTML avec un nonce expiré. Videz cache plugin + cache navigateur.

Cas particulier : AJAX (admin-ajax.php)

Pour une action AJAX, utilisez check_ajax_referer() et renvoyez du JSON propre. Ne mélangez pas des echo arbitraires, sinon vous cassez la réponse.

<?php
/**
 * AJAX sécurisé.
 */

add_action( 'wp_ajax_mon_marketing_preview', 'mon_marketing_ajax_preview' );

function mon_marketing_ajax_preview() {
	// Vérifie le nonce envoyé (POST/GET). Ici, on attend "nonce" côté JS.
	check_ajax_referer( 'mon_marketing_preview', 'nonce' );

	if ( ! current_user_can( 'manage_options' ) ) {
		wp_send_json_error( array( 'message' => 'Accès refusé.' ), 403 );
	}

	$pixel_id = isset( $_POST['pixel_id'] ) ? sanitize_text_field( wp_unslash( $_POST['pixel_id'] ) ) : '';

	wp_send_json_success(
		array(
			'pixel_id' => $pixel_id,
			'preview'  => sprintf( 'Prévisualisation pour %s', $pixel_id ),
		)
	);
}

Cas particulier : REST API (endpoints personnalisés)

Pour REST, le nonce sert surtout à prouver que l’appel vient d’un contexte WP (admin/front) via X-WP-Nonce. Mais la vraie barrière reste permission_callback. Si vous mettez __return_true, vous ouvrez la porte à des accès non souhaités.

<?php
/**
 * Endpoint REST sécurisé.
 */

add_action( 'rest_api_init', function () {
	register_rest_route(
		'mon-site/v1',
		'/marketing/pixel',
		array(
			'methods'             => 'POST',
			'callback'            => 'mon_marketing_rest_update_pixel',
			'permission_callback' => function ( WP_REST_Request $request ) {
				// Contrôle d'accès : indispensable.
				return current_user_can( 'manage_options' );
			},
			'args'                => array(
				'pixel_id' => array(
					'type'              => 'string',
					'required'          => false,
					'sanitize_callback' => 'sanitize_text_field',
				),
			),
		)
	);
} );

function mon_marketing_rest_update_pixel( WP_REST_Request $request ) {
	$pixel_id = (string) $request->get_param( 'pixel_id' );

	if ( $pixel_id !== '' && ! preg_match( '/^[A-Za-z0-9_-]{4,64}$/', $pixel_id ) ) {
		return new WP_Error( 'invalid_pixel', 'Pixel ID invalide.', array( 'status' => 400 ) );
	}

	update_option( 'mon_pixel_id', $pixel_id, false );

	return rest_ensure_response(
		array(
			'success'  => true,
			'pixel_id' => $pixel_id,
		)
	);
}

Côté JavaScript (admin ou front), vous envoyez typiquement X-WP-Nonce avec un nonce créé pour wp_rest. Lisez la doc officielle REST : developer.wordpress.org/rest-api.


Configuration serveur

Les nonces protègent contre le CSRF, mais ils ne compensent pas une surface d’attaque trop exposée. Sur des sites WordPress 6.9.4, je combine généralement nonce + permissions + durcissement serveur.

.htaccess (Apache) : bloquer l’accès direct à des fichiers sensibles

Si vous êtes sur Apache avec .htaccess, ces règles réduisent des vecteurs classiques. Attention : selon l’hébergement, certaines directives peuvent être interdites. Testez sur staging.

# Protéger wp-config.php
<Files "wp-config.php">
  Require all denied
</Files>

# Empêcher l'exécution de PHP dans uploads (souvent utile après compromission)
<Directory "wp-content/uploads">
  <FilesMatch ".php$">
    Require all denied
  </FilesMatch>
</Directory>

# Headers de base (si mod_headers est activé)
<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 Permissions-Policy "geolocation=(), microphone=(), camera=()"
</IfModule>

Note : les headers peuvent aussi être gérés au niveau Nginx/Cloudflare. Évitez de doubler partout sans vérifier, sinon vous allez vous retrouver avec des politiques incohérentes.

wp-config.php : forcer SSL en admin et durcir quelques constantes

Si votre site est en HTTPS (il doit l’être), forcer SSL en admin limite des scénarios de fuite de cookies sur des environnements mal configurés.

<?php
// Forcer l'administration en HTTPS (utile si votre infra est cohérente en SSL).
define( 'FORCE_SSL_ADMIN', true );

// Désactiver l'éditeur de fichiers dans l'admin (réduit l'impact après vol de session).
define( 'DISALLOW_FILE_EDIT', true );

// Optionnel : désactiver l'installation/MAJ via l'admin si votre process est géré autrement.
// Attention : ça change votre workflow.
// define( 'DISALLOW_FILE_MODS', true );

Headers HTTP : SameSite cookies (à comprendre, pas à bricoler)

Le CSRF est fortement lié aux cookies. Les attributs SameSite aident, mais WordPress et votre serveur gèrent ça ensemble. Je déconseille de “réécrire” les cookies WordPress à la volée avec des hacks .htaccess : vous risquez de casser l’auth, surtout avec des SSO, des sous-domaines, ou des plugins de cache.

À la place :

  • Vérifiez la politique cookies côté serveur/CDN.
  • Gardez les nonces comme barrière applicative.
  • Ajoutez une CSP si vous maîtrisez vos scripts (ça n’empêche pas le CSRF, mais réduit d’autres attaques).

Vérifier si votre site est vulnérable

Vous cherchez des actions sensibles qui n’appellent jamais check_admin_referer(), check_ajax_referer() ou wp_verify_nonce(), ou des endpoints REST trop permissifs.

Audit rapide du code avec WP-CLI (recherche de patterns)

Sur un serveur avec WP-CLI, vous pouvez déjà repérer des zones à risque. Ce n’est pas parfait, mais ça attrape beaucoup de cas.

# 1) Trouver des handlers admin-post (souvent des actions sensibles)
wp eval 'echo "OKn";' 

# 2) Grep dans wp-content (plugins + thèmes)
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "admin_post_" wp-content
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "wp_ajax_" wp-content
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "register_rest_route" wp-content

# 3) Vérifier si les checks nonce existent près des handlers
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "check_admin_referer|check_ajax_referer|wp_verify_nonce" wp-content

Ce que vous voulez voir :

  • Chaque handler admin_post_* a un check_admin_referer() (ou wp_verify_nonce() + gestion d’erreur).
  • Chaque handler wp_ajax_* a un check_ajax_referer().
  • Chaque route REST a une permission_callback stricte.

Logs : indices concrets

Dans l’expérience terrain, un CSRF laisse parfois des traces “bizarres” :

  • Modifications d’options à des heures improbables, sans IP connue de l’admin.
  • Succession de requêtes POST vers /wp-admin/admin-post.php ou /wp-admin/admin-ajax.php avec un referer externe (quand il est présent).
  • Actions déclenchées juste après la visite d’une page externe (difficile à corréler, mais visible sur certains WAF).

Tableau de diagnostic (symptômes fréquents)

Symptôme Cause probable Vérification Solution
Options qui changent “toutes seules” Formulaire admin sans nonce (CSRF) Rechercher update_option dans un handler sans check_admin_referer Ajouter nonce + vérif capacité + PRG
AJAX répond 200 même sans être connecté Action AJAX exposée via wp_ajax_nopriv_* ou absence de contrôle Rechercher add_action('wp_ajax_nopriv_ Limiter aux utilisateurs connectés + nonce + validation
Erreurs “Are you sure you want to do this?” Nonce manquant/expiré ou cache qui sert un vieux formulaire Vérifier cache page / minification / CDN Exclure la page du cache, régénérer le formulaire, éviter le cache HTML sur écrans avec nonce
Endpoint REST modifie des données sans auth permission_callback trop permissif Inspecter register_rest_route Implémenter current_user_can() + validation + éventuellement nonce REST

Erreurs de sécurité fréquentes

Erreur Risque Solution
Ajouter un nonce dans le formulaire, mais oublier de le vérifier côté serveur Protection inexistante (CSRF possible) Ajouter check_admin_referer() / check_ajax_referer() dans le handler
Vérifier le nonce, mais oublier current_user_can() Un utilisateur connecté non autorisé peut exécuter l’action Contrôler les capacités avant toute écriture
Utiliser le même action name de nonce partout (“save_settings” générique) Risque d’erreurs de logique et de réutilisation involontaire Un nonce par action sensible : mon_plugin_export, mon_plugin_save, etc.
Mettre l’action destructrice en GET (ex. ?delete=1) CSRF plus simple, déclenchement accidentel via liens Utiliser POST + nonce + confirmation
Générer un nonce trop tôt, puis le cacher derrière un cache HTML Nonce expiré, erreurs aléatoires, contournements de flux Exclure du cache les pages avec formulaires sensibles, ou générer côté client via REST quand pertinent
Copier un vieux snippet d’un tutoriel pré-PHP 8.x Warnings/erreurs, checks cassés, logique non exécutée Valider sur PHP 8.1+, activer WP_DEBUG sur staging
Tester directement sur production sans sauvegarde Admin cassée, perte de temps, indisponibilité Staging + sauvegarde + plan de rollback
Utiliser un hook au mauvais moment (handler déclaré dans un fichier non chargé) Le check nonce ne s’exécute jamais Mettre le code dans un plugin (mu-plugin si critique) ou functions.php du thème enfant

Checklist de durcissement

  • Chaque formulaire qui écrit (options, posts, users, fichiers) contient wp_nonce_field().
  • Chaque handler qui écrit appelle check_admin_referer() / check_ajax_referer() avant toute action.
  • Chaque handler vérifie les capacités avec current_user_can() (et pas seulement “être connecté”).
  • Les actions destructrices passent en POST, pas en GET.
  • Les entrées sont unslash + sanitize + validate (et échappées à l’affichage).
  • Les endpoints REST ont une permission_callback stricte (pas de __return_true en production).
  • Les pages contenant des nonces ne sont pas servies par un cache HTML public (ou sont correctement exclues).
  • DISALLOW_FILE_EDIT est activé si votre workflow le permet.
  • Vous avez un WAF / protection brute force, et des logs exploitables (au minimum access/error logs).
  • Vous maintenez WordPress, thèmes et plugins à jour (WordPress 6.9.4 au moment où vous lisez ceci).

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

Un CSRF seul ne laisse pas toujours une “porte” persistante, mais il peut avoir déclenché une action grave (création d’utilisateur admin, changement d’e-mail, injection de code via réglage, etc.). Voici un plan d’action que j’applique en intervention.

  1. Mettre le site en maintenance (temporairement) si vous suspectez une compromission active. Évitez de continuer à publier/modifier : vous polluez les indices.
  2. Sauvegarder à froid : fichiers + base de données, avant toute suppression. Conservez cette copie pour analyse.
  3. Identifier ce qui a changé :
    • Liste des utilisateurs admin, e-mails, dates de création.
    • Options sensibles : siteurl, home, options de plugins de cache/SEO/sécurité.
    • Fichiers récemment modifiés dans wp-content/ (surtout uploads, mu-plugins, plugins).
  4. Révoquer l’accès :
    • Réinitialiser les mots de passe (admins en priorité).
    • Regénérer les AUTH_KEY/SALT dans wp-config.php (déconnecte les sessions). Utilisez le générateur officiel : api.wordpress.org/secret-key/1.1/salt/.
    • Vérifier les clés SSH/FTP, tokens CDN, accès hébergeur.
  5. Nettoyer :
    • Mettre à jour WordPress core + plugins + thème depuis des sources officielles.
    • Supprimer les plugins/thèmes inutilisés (souvent oubliés, parfois vulnérables).
    • Comparer les fichiers à une version propre si possible.
  6. Corriger la cause :
    • Ajouter nonces et contrôles de capacités sur les actions identifiées.
    • Si le problème vient d’un plugin, mettre à jour ou remplacer. Si aucune mise à jour n’existe, désactiver.
  7. Contrôler après nettoyage :
    • Scanner les logs sur 7 à 30 jours pour vérifier la fin des requêtes suspectes.
    • Vérifier l’intégrité des comptes admin.
  8. Remettre en ligne avec surveillance renforcée (logs + alertes) pendant quelques jours.

Si vous suspectez une injection plus large (uploads PHP, backdoor), traitez ça comme une compromission complète, pas comme un “petit bug de nonce”.


Conseils de maintenance et compatibilité

Les nonces sont simples, mais les sites réels ont des contraintes : page builders, cache, AJAX, multi-admin, formulaires front. Voilà ce qui tient bien dans le temps.

Performance : nonce et cache

Générer un nonce n’est pas coûteux. Le vrai piège performance vient du cache HTML : servir une page mise en cache avec un nonce expiré crée des erreurs et pousse les équipes à “désactiver la vérif”. Ne faites pas ça.

  • Pour une page d’admin : pas de cache HTML public, donc RAS.
  • Pour un formulaire front (contact, vote, wishlist) : excluez la page du cache, ou générez le nonce via une requête AJAX/REST au chargement.

Compatibilité Divi 5, Elementor, Avada

Sur des sites avec Divi 5, Elementor ou Avada, le problème arrive souvent quand un formulaire est ajouté via :

  • un module HTML/Code (Divi/Elementor),
  • un shortcode maison,
  • un widget “formulaire” d’un plugin tiers qui déclenche une action custom.

Mes règles :

  • Si vous faites un shortcode qui affiche un formulaire : mettez wp_nonce_field() dans le HTML rendu par le shortcode, et traitez la soumission via admin-post.php ou une route REST.
  • Si vous faites un widget Elementor ou module Divi : même logique, nonce au rendu, check côté handler. Le builder ne change rien au besoin de nonce.
  • Si un plugin de formulaire gère déjà la sécurité : ne doublez pas au hasard. Vérifiez sa doc et son code. Beaucoup de plugins sérieux utilisent déjà nonces + honeypots + rate limiting.

SEO et UX

Le CSRF n’est pas directement un sujet SEO, mais une compromission l’est. Un CSRF qui change des réglages SEO, des redirections, ou injecte du contenu peut faire chuter un site. Côté UX, le PRG (redirect après POST) évite les messages “Confirmer la resoumission du formulaire” et réduit les tickets.

Une note sur la fiabilité du code (WordPress 6.9.4 / PHP 8.1)

Évitez les snippets “magiques” qui :

  • utilisent des fonctions non chargées (déclarées dans un fichier inclus trop tard),
  • mélangent echo et wp_send_json_* en AJAX,
  • ou réutilisent un nonce pour plusieurs actions.

Si vous devez industrialiser : mettez ces handlers dans un plugin dédié (ou mu-plugin si critique), versionné, testé, et déployé proprement.


Ressources

FAQ

Un nonce WordPress, c’est un token aléatoire unique ?

Pas exactement. C’est un jeton “à fenêtre de temps” lié à une action et à l’utilisateur/session. Il est conçu pour empêcher le CSRF, pas pour chiffrer ou authentifier comme un JWT. La doc officielle détaille le modèle : developer.wordpress.org/apis/security/nonces/.

Nonce = je n’ai plus besoin de current_user_can() ?

Non. Le nonce prouve que la requête vient d’un écran légitime, mais il ne dit pas si l’utilisateur a le droit d’exécuter l’action. Faites les deux : capabilities + nonce.

Pourquoi j’ai “Are you sure you want to do this?” alors que j’ai mis le nonce ?

Dans la pratique, c’est souvent :

  • mauvais nom de champ (vous vérifiez mon_nonce, mais le formulaire envoie mon-nonce),
  • action string différente entre wp_nonce_field() et check_admin_referer(),
  • page servie depuis un cache avec un nonce expiré.

Dois-je mettre le nonce dans l’URL (GET) ?

Pour des actions via lien (ex. “désactiver X”), WordPress le fait parfois, mais je réserve ça aux actions non destructrices ou avec confirmation. Pour supprimer/modifier, préférez POST. Si vous devez passer par GET, utilisez wp_nonce_url() et vérifiez côté serveur, mais gardez à l’esprit que des URLs se loggent (analytics, referers, logs serveur).

Quelle différence entre check_admin_referer() et wp_verify_nonce() ?

wp_verify_nonce() renvoie un résultat que vous gérez vous-même. check_admin_referer() est un wrapper pratique côté admin : il récupère le nonce dans la requête et déclenche un écran d’erreur standard si invalide. Pour des handlers API/JSON, j’utilise souvent wp_verify_nonce() + réponse contrôlée, ou check_ajax_referer() en AJAX.

Sur un formulaire front, je peux utiliser check_admin_referer() ?

Techniquement oui, mais ce n’est pas le meilleur choix : le flux d’erreur et les messages sont orientés admin. Pour du front, je préfère wp_verify_nonce() et une gestion d’erreur UX (message, redirection, code HTTP si API).

Les nonces protègent-ils contre les attaques XSS ?

Non. Une XSS permet souvent de lire le nonce dans le DOM et de faire des requêtes légitimes. Si vous avez une XSS, considérez vos nonces comme compromis. Traitez la XSS (échappement, CSP, sanitization) en priorité.

Un nonce peut-il être réutilisé ?

Oui, pendant sa fenêtre de validité. C’est normal. Si vous avez besoin d’un jeton strictement “one-time”, il faut une logique applicative supplémentaire (stockage serveur et invalidation), mais c’est rarement nécessaire pour des réglages WordPress classiques.

Je dois protéger aussi les actions “lecture seule” ?

Si l’action ne modifie rien et n’expose pas de données sensibles, le nonce est moins critique. Mais dès que vous avez un export, une génération de lien signé, ou une action coûteuse, je mets un nonce (et un contrôle de capacité) pour éviter l’abus.

Comment ça se passe avec Elementor/Divi/Avada ?

Le builder ne change pas le modèle de sécurité. Le piège vient plutôt du fait que le formulaire est “juste du HTML” dans un module : sans handler sécurisé, vous finissez avec un POST traité dans init sans nonce. Gardez une architecture claire : rendu (builder/shortcode) + handler (admin-post/AJAX/REST) + nonce + capabilities.

Quelle est la règle d’or ?

Si une requête déclenche une action qui vous ferait peur si elle était exécutée à votre insu : nonce + contrôle d’accès + validation, et idéalement POST + redirection.