Si vous avez déjà vu passer dans vos logs une requête du type UNION SELECT ou SLEEP(5) injectée dans un paramètre ?s= ou ?id=, vous avez rencontré une tentative d’injection SQL. Sur WordPress 6.9.4 (avril 2026), le cœur est plutôt solide, mais les snippets “rapides”, les plugins maison, et certaines intégrations de page builders restent des points d’entrée fréquents.
La menace
Une injection SQL (SQLi) survient quand un attaquant parvient à influencer une requête SQL construite dynamiquement, sans échappement/paramétrage correct. Le problème vient rarement de wpdb lui-même. Il vient de la manière dont on assemble la requête.
Ce qu’un attaquant peut faire si votre site est vulnérable dépend des privilèges SQL du compte MySQL utilisé par WordPress (souvent trop larges) et du type d’injection (in-band, blind, time-based). Dans la pratique, j’ai vu :
- Exfiltration de données : emails, hashes de mots de passe (à casser hors-ligne), clés API stockées en options, contenus privés.
- Élévation indirecte : récupération d’un hash admin + réinitialisation, ou injection dans
wp_options(si écriture possible) pour activer une porte dérobée. - Altération de contenu : liens spam injectés, redirections, modifications d’articles, SEO poisoning.
- Déni de service : requêtes lourdes,
SLEEP(), scans agressifs qui saturent la base.
La fréquence réelle : la SQLi fait partie du “top” des classes de vulnérabilités web depuis des années (OWASP). Sur WordPress, je la croise surtout dans :
- des plugins custom (CPT, filtres admin, recherches avancées) écrits vite,
- des endpoints AJAX/REST qui prennent des paramètres et construisent des requêtes,
- des “snippets” copiés d’anciens tutos (pré-WordPress moderne / pré-PHP 8),
- des intégrations WooCommerce/CRM où l’on veut “optimiser” en SQL direct.
En langage simple : si vous concaténez une variable utilisateur dans du SQL, vous jouez à la roulette. Même si vous “cast” en (int) une fois sur deux, même si vous “nettoyez” avec sanitize_text_field(). La seule stratégie fiable est de paramétrer la requête (préparation) et de limiter ce que la variable peut influencer (identifiants, listes, ORDER BY, LIMIT).
Résumé rapide
- Utilisez
$wpdb->prepare()pour toute donnée dynamique dans une requête SQL (valeurs, dates, IDs, LIKE, IN, LIMIT géré proprement). - Ne mettez jamais un nom de colonne, une table, ou un
ORDER BYdirectement depuis l’utilisateur : whitelist stricte obligatoire. sanitize_*()n’est pas une protection SQL : c’est de la validation/normalisation, pas du paramétrage.- Protégez l’entrée (capabilities, nonces, REST permissions), et la requête (prepare + whitelist) : les deux sont nécessaires.
- Détectez via logs, requêtes lentes, patterns SQLi, et audit de code (grep sur
$wpdb->get_results("SELECT+ concat). - Durcissez : moindre privilège MySQL, WAF/headers, désactivation de l’éditeur de fichiers, et monitoring.
Code vulnérable — ce qu’il ne faut PAS faire
Exemple réaliste : un plugin maison ajoute une recherche “avancée” sur un CPT book avec filtre par auteur et tri. Le dev concatène des paramètres $_GET dans une requête. C’est le genre de code que je retrouve souvent dans des sites Elementor/Divi/Avada où un “module” custom appelle un shortcode qui interroge la base directement.
<?php
/**
* Exemple VULNÉRABLE : ne copiez pas ce code.
* WordPress 6.9.4 / PHP 8.1+.
*/
add_shortcode('books_search', function ($atts) {
global $wpdb;
// Piège classique : on "nettoie" mais on concatène quand même.
$author = isset($_GET['author']) ? sanitize_text_field($_GET['author']) : '';
$order = isset($_GET['order']) ? sanitize_text_field($_GET['order']) : 'DESC';
$limit = isset($_GET['limit']) ? sanitize_text_field($_GET['limit']) : '10';
// Autre piège : LIKE construit à la main + ORDER BY dynamique.
$sql = "
SELECT p.ID, p.post_title
FROM {$wpdb->posts} p
WHERE p.post_type = 'book'
AND p.post_status = 'publish'
AND p.post_author IN (
SELECT ID FROM {$wpdb->users}
WHERE user_login LIKE '%{$author}%'
)
ORDER BY p.post_date {$order}
LIMIT {$limit}
";
$rows = $wpdb->get_results($sql);
if (empty($rows)) {
return '<p>Aucun résultat.</p>';
}
$out = '<ul>';
foreach ($rows as $row) {
$out .= '<li>' . esc_html($row->post_title) . '</li>';
}
$out .= '</ul>';
return $out;
});
Pourquoi c’est exploitable (sans “mode d’emploi” pour attaquer)
Trois surfaces d’attaque :
LIKE '%{$author}%': l’utilisateur contrôle une partie d’une chaîne SQL. Même “sanitizé”, il peut casser la logique (quotes) ou forcer des patterns coûteux. La préparation gère l’échappement des quotes, mais vous devez aussi gérer les jokers de LIKE.ORDER BY ... {$order}:prepare()ne protège pas les identifiants SQL (ASC/DESC, noms de colonnes). Ici, l’utilisateur influence directement la structure de la requête.LIMIT {$limit}: même problème. Un LIMIT mal contrôlé peut devenir une injection (selon contexte) ou un DoS (LIMIT énorme).
Ce qui se passe en coulisses : MySQL reçoit une chaîne SQL finale. Si l’attaquant arrive à injecter des morceaux de syntaxe, il peut modifier les conditions, joindre d’autres tables, ou déclencher des erreurs temporelles. Même si le compte MySQL n’a pas le droit d’écrire, la lecture suffit souvent pour voler des données.
Pièges que je vois souvent autour de ce code
- Le snippet est collé dans
functions.phpdu thème parent (perdu à la mise à jour) au lieu d’un thème enfant ou d’un plugin mu. - Le code est collé dans un plugin de snippets qui exécute avant que certains hooks soient chargés, ou avec une priorité qui casse des shortcodes.
- Test sur production “pour voir”, sans sauvegarde, puis indexation par des bots qui déclenchent le endpoint en boucle.
- Conflit de cache : la page builder met en cache le rendu du shortcode, et vous ne voyez pas vos corrections tant que vous n’avez pas purgé (Divi/Elementor + cache serveur).
Code sécurisé — la bonne implémentation
Objectif : même fonctionnalité, mais avec validation stricte + préparation + whitelist sur ce qui ne peut pas être paramétré. Je vais aussi corriger un point d’architecture : évitez de taper directement wp_users pour une recherche “auteur”. Utilisez plutôt des IDs validés, ou laissez WP_Query faire le travail quand c’est possible. Mais comme le sujet est $wpdb->prepare(), je montre une version SQL directe, correctement défensive.
Version sécurisée (shortcode) avec prepare + whitelist
<?php
/**
* Exemple SÉCURISÉ : shortcode de recherche de livres.
* Compatible WordPress 6.9.4+ / PHP 8.1+.
*/
add_shortcode('books_search', function ($atts) {
global $wpdb;
// 1) Validation/normalisation des entrées (ce n'est PAS la protection SQL)
$author = isset($_GET['author']) ? (string) wp_unslash($_GET['author']) : '';
$author = trim($author);
$order_raw = isset($_GET['order']) ? (string) wp_unslash($_GET['order']) : 'DESC';
$order_raw = strtoupper(trim($order_raw));
$limit_raw = isset($_GET['limit']) ? (string) wp_unslash($_GET['limit']) : '10';
$limit = (int) $limit_raw;
// 2) Whitelist stricte pour ORDER BY (prepare ne gère pas les identifiants)
$order = in_array($order_raw, array('ASC', 'DESC'), true) ? $order_raw : 'DESC';
// 3) Limitation défensive du LIMIT (anti-DoS)
if ($limit < 1) {
$limit = 10;
}
if ($limit > 50) {
$limit = 50;
}
// 4) LIKE : on échappe les jokers (% _) puis on ajoute nos % autour
// wpdb::esc_like() est fait pour ça.
$like = '%' . $wpdb->esc_like($author) . '%';
// 5) Requête paramétrée : toutes les VALEURS dynamiques passent par prepare()
// Note : on n'essaie pas de préparer ORDER ou les noms de tables/colonnes.
$sql = $wpdb->prepare(
"
SELECT p.ID, p.post_title
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->users} u ON u.ID = p.post_author
WHERE p.post_type = %s
AND p.post_status = %s
AND u.user_login LIKE %s
ORDER BY p.post_date {$order}
LIMIT %d
",
'book',
'publish',
$like,
$limit
);
$rows = $wpdb->get_results($sql);
if (empty($rows)) {
return '<p>Aucun résultat.</p>';
}
$out = '<ul>';
foreach ($rows as $row) {
$out .= '<li>' . esc_html($row->post_title) . '</li>';
}
$out .= '</ul>';
return $out;
});
Explication simple (ce qui vous protège réellement)
wp_unslash(): WordPress ajoute parfois des slashes. Vous normalisez l’entrée avant de la traiter.- Whitelist sur
ASC/DESC: vous empêchez l’utilisateur d’injecter de la syntaxe dansORDER BY. Sans whitelist,prepare()ne peut rien faire. - Bornes sur
LIMIT: ce n’est pas qu’une question d’injection. Un LIMIT à 50000, c’est souvent un DoS applicatif. $wpdb->esc_like(): vous évitez que l’utilisateur transforme votre LIKE en “match everything” ou en requête pathologique.$wpdb->prepare(): la requête finale est construite avec des placeholders (%s,%d). MySQL reçoit une requête où les valeurs sont correctement échappées.
Explication technique (les détails qui comptent)
Placeholders : utilisez %d pour les entiers, %f pour les floats, %s pour les chaînes. Si vous forcez un entier avec (int), gardez quand même %d : c’est une double barrière.
Identifiants SQL (noms de colonnes, tables, direction ORDER, morceaux de SQL) : prepare() ne doit pas être utilisé pour ça. La stratégie correcte est :
- soit une whitelist (mapping “input → fragment SQL connu”),
- soit une suppression de fonctionnalité (ne pas exposer ORDER BY libre),
- soit une réécriture via WP_Query, qui gère certains cas, mais pas tous.
Cas avancé : ORDER BY sur colonnes multiples (mapping)
Si vous devez autoriser plusieurs tris (date, titre, popularité), faites un mapping strict. Ne concaténez jamais une valeur brute.
<?php
$sort_raw = isset($_GET['sort']) ? (string) wp_unslash($_GET['sort']) : 'date_desc';
$sort_raw = strtolower(trim($sort_raw));
$sort_map = array(
'date_desc' => 'p.post_date DESC',
'date_asc' => 'p.post_date ASC',
'title_asc' => 'p.post_title ASC',
'title_desc' => 'p.post_title DESC',
);
// Fragment SQL contrôlé (valeur par défaut si entrée inconnue)
$order_by_sql = $sort_map[$sort_raw] ?? $sort_map['date_desc'];
// Puis dans la requête :
$sql = $wpdb->prepare(
"SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_type = %s ORDER BY {$order_by_sql} LIMIT %d",
'book',
$limit
);
Cas avancé : IN() avec liste d’IDs
Le cas IN() est un nid à erreurs. Ne faites pas IN (" . implode(',', $ids) . "). En WordPress moderne, vous pouvez construire une liste de placeholders dynamiques, puis passer les valeurs à prepare().
<?php
$ids_raw = isset($_GET['ids']) ? (string) wp_unslash($_GET['ids']) : '';
$ids = array_filter(array_map('absint', explode(',', $ids_raw)));
// Anti-abus : max 100 IDs
$ids = array_slice($ids, 0, 100);
if (empty($ids)) {
return '<p>Aucun ID valide.</p>';
}
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$sql = $wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ($placeholders)",
...$ids // PHP 8.1+ : unpacking d'arguments
);
$rows = $wpdb->get_results($sql);
Compatibilité Divi 5 / Elementor / Avada : où le risque apparaît
- Divi 5 : un module custom qui rend un shortcode et accepte des paramètres via URL (filtre, tri). Le risque est identique : le module ne “sécurise” rien par magie. Si vous interrogez SQL, vous préparez.
- Elementor : widgets custom + contrôles (select, text). Même si le contrôle est un select, ne supposez pas que la valeur ne peut pas être falsifiée. Whitelist côté serveur.
- Avada / Fusion Builder : shortcodes avancés qui prennent des attributs (ex:
[my_list order="..."]). Les attributs de shortcodes sont aussi de l’entrée utilisateur (éditeur, rôle, import). Préparez pareil.
Configuration serveur
La SQLi est un problème applicatif, mais la configuration serveur réduit l’impact. L’objectif : limiter l’écriture, améliorer la détection, et réduire les surfaces “faciles”.
wp-config.php : désactiver l’éditeur de fichiers et forcer SSL admin
<?php
// Désactive l'éditeur de fichiers dans l'admin (réduit l'impact post-compromission)
define('DISALLOW_FILE_EDIT', true);
// Optionnel : empêche l'installation/mise à jour de plugins/thèmes depuis l'admin
// Attention : impact sur votre workflow de maintenance.
define('DISALLOW_FILE_MODS', true);
// Force l'accès admin en HTTPS si votre site est correctement configuré en TLS
define('FORCE_SSL_ADMIN', true);
MySQL : moindre privilège (à faire côté hébergeur)
Dans mon expérience, beaucoup de sites tournent encore avec un utilisateur MySQL ayant des privilèges trop larges. Pour WordPress, vous voulez typiquement : SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX sur la base WordPress uniquement, pas globalement. Évitez GRANT ALL ON *.*.
.htaccess (Apache) : bloquer l’exécution PHP dans uploads
Ce n’est pas une protection SQLi directe, mais c’est une réduction d’impact classique : beaucoup de compromissions enchaînent SQLi → écriture de fichier → exécution.
# Dans /wp-content/uploads/.htaccess (Apache)
<FilesMatch ".php$">
Require all denied
</FilesMatch>
Headers HTTP : durcissement de base
Ces headers ne stoppent pas une SQLi, mais ils réduisent d’autres vecteurs souvent couplés (XSS, clickjacking). Ajoutez-les au niveau serveur (Apache/Nginx) ou via un plugin de sécurité sérieux.
# Exemple (Apache) - à adapter à votre environnement
Header always set X-Frame-Options "SAMEORIGIN"
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=()"
WAF/CDN : utile contre le bruit, pas une excuse
Un WAF (Cloudflare/WAF hébergeur) réduit le volume de scans SQLi et bloque des patterns connus. Mais la minute où vous avez une injection “propre” (pas de payload bruyant), le WAF n’est pas une garantie. Traitez-le comme une ceinture, pas comme des freins.
Vérifier si votre site est vulnérable
Vous cherchez deux choses : du code à risque et des symptômes d’exploitation. Les deux approches se complètent.
Audit rapide du code (grep ciblé)
Sur un serveur, commencez par repérer les concaténations SQL autour de $wpdb. Ce n’est pas parfait, mais ça sort vite les pires cas.
# Depuis la racine WordPress
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor --exclude=*.min.* "$wpdb->get_results|$wpdb->get_var|$wpdb->query|$wpdb->get_row" wp-content | head -n 50
# Cherche les SELECT construits en concaténation (heuristique)
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "SELECT .*" ." wp-content | head -n 50
# Cherche les prepare() absents sur des requêtes longues
grep -RIn "$wpdb->prepare" wp-content | head -n 50
Ce que vous cherchez ensuite, fichier par fichier :
- présence de
$_GET,$_POST,$_REQUESTdans la construction du SQL, - présence de fragments dynamiques :
ORDER BY,LIMIT,IN(), noms de colonnes, - absence de whitelist,
- requêtes SQL dans des callbacks AJAX/REST sans vérification de capability/nonce.
WP-CLI : vérifier les options et anomalies courantes
WP-CLI ne “détecte” pas une SQLi, mais il aide à repérer des traces (modifs d’options, comptes suspects).
# Liste des admins (cherchez un compte inconnu)
wp user list --role=administrator --fields=ID,user_login,user_email,registered --format=table
# Vérifie les options sensibles souvent modifiées lors de compromissions
wp option get siteurl
wp option get home
wp option get active_plugins --format=json
# Cherche des options autoload énormes (symptôme de pollution / malware)
wp db query "SELECT option_name, LENGTH(option_value) AS bytes FROM wp_options WHERE autoload='yes' ORDER BY bytes DESC LIMIT 20;"
Logs : patterns à surveiller
Au niveau HTTP (access logs), je surveille :
- pics de requêtes avec paramètres longs,
- répétitions sur
admin-ajax.php, endpoints REST custom, pages de recherche, - codes 500/502 corrélés à des query strings particulières.
Au niveau MySQL (slow query log si activé), cherchez :
- requêtes avec
LIKE '%...%'surwp_usersouwp_options, - requêtes répétées avec temps constant (symptôme time-based),
- requêtes qui explosent en temps après une mise en ligne d’un nouveau module.
Tableau diagnostic (symptômes concrets)
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
Pic de 500 sur une page filtrée (?order=, ?sort=) |
Injection via ORDER BY ou SQL cassé |
Reproduire avec logs d’erreurs PHP + activer temporairement WP_DEBUG_LOG en staging |
Whitelist stricte (mapping), supprimer l’entrée libre, corriger la requête |
Requêtes lentes soudaines sur wp_users |
LIKE non contrôlé / scans automatisés | Slow query log + access logs corrélés | esc_like() + index/architecture (éviter recherche sur login) + WAF rate-limit |
| Comptes admin inconnus | Compromission (pas forcément SQLi, mais fréquent en chaîne) | wp user list + logs de création |
Rotation des secrets, suppression comptes, analyse malware |
| Redirections SEO/spam | Injection en base (options, posts) ou plugin compromis | Comparer wp_options et contenu, scanner fichiers |
Nettoyage DB + fichiers, réinstallation propre, durcissement |
| Le correctif “ne marche pas” | Cache page builder / cache serveur / opcode | Purger cache (Divi/Elementor/Avada + serveur) et invalider CDN | Procédure de purge + versionner et déployer proprement |
Erreurs de sécurité fréquentes
| Erreur | Risque | Solution | |
|---|---|---|---|
Concaténer $_GET dans une requête SQL “parce que c’est juste un tri” |
SQLi via ORDER BY / altération logique |
Whitelist (mapping) côté serveur, jamais de SQL libre | |
Penser que sanitize_text_field() “empêche l’injection” |
Fausse sécurité, contournements possibles | Validation + $wpdb->prepare() (paramétrage) |
|
Utiliser $wpdb->prepare() pour un nom de colonne/table |
Requête cassée, ou contournement si vous retombez en concat | Whitelist stricte pour les identifiants SQL | |
Oublier $wpdb->esc_like() avant un LIKE |
Match trop large, perf dégradée, surface d’abus | esc_like() + bornes + index/architecture |
|
| Copier le code au mauvais endroit (thème parent, snippet runner instable) | Correctif perdu, site cassé à la MAJ | Thème enfant, plugin dédié, ou mu-plugin versionné | |
| Oublier un point-virgule / parenthèse dans un hotfix | Fatal error, downtime | Staging + CI (php -l) + déploiement atomique | |
| Utiliser un hook inadapté (ex: exécuter une requête trop tôt) | Fonctions non chargées, comportements aléatoires | Hooks adaptés (init, rest_api_init, wp_ajax_*) + priorités |
|
| Oublier nonce/capabilities sur AJAX/REST | Abus par visiteurs non authentifiés, amplification | current_user_can(), nonces, permissions REST |
|
| Tester sur production sans sauvegarde | Perte de données, indisponibilité | Snapshot + staging + plan de rollback | |
| Conflit cache : le vieux rendu reste servi | Vous croyez avoir corrigé, mais l’attaque continue | Purge cache plugin + cache serveur + CDN | Procédure de purge et invalidation |
| Code d’ancien tutoriel incompatible (PHP 8.1+) | Warnings/fatals, comportements inattendus | Mettre à jour les patterns (unpacking, types, wp_unslash) |
Checklist de durcissement
- Audit : recherchez toutes les occurrences de
$wpdb->get_results()/query()et vérifiez la présence deprepare(). - Whitelist : tout ce qui ressemble à identifiant SQL (colonnes, ORDER, direction, fragments) doit venir d’une liste fermée.
- Bornes : imposez des limites (LIMIT max, tailles de chaînes, tailles de listes IN).
- REST/AJAX : ajoutez
permission_callbackcôté REST et nonces/capabilities côté AJAX. - Moindre privilège MySQL : utilisateur limité à la base WordPress, pas de privilèges globaux.
- Désactivez l’éditeur de fichiers via
DISALLOW_FILE_EDIT. - WAF/rate-limit sur endpoints sensibles (admin-ajax, recherche, filtres) pour réduire le bruit.
- Logs : activez slow query log (si possible) et centralisez les access logs.
- Mises à jour : core 6.9.4+ et plugins à jour, supprimez les plugins inactifs (surface inutile).
- Staging : testez les correctifs SQL en staging, puis déployez (pas d’édition live).
Que faire si le site est déjà compromis ?
Si vous suspectez une SQLi ayant mené à une exfiltration, partez du principe que des secrets ont fuité (mots de passe, clés API). Votre priorité n’est pas “réparer le bug” en premier. C’est contenir et reprendre le contrôle.
- Mettre le site en maintenance (page statique ou mode maintenance) si l’attaque est active. Évitez de laisser un endpoint vulnérable ouvert pendant que vous investiguez.
- Snapshot immédiat (fichiers + base) pour analyse forensique. Ne travaillez pas “sans copie”, vous perdrez les preuves.
- Rotation des secrets :
- mots de passe admins (forcez la réinitialisation),
- clés API (Stripe, SMTP, CDN, reCAPTCHA, etc.),
- mots de passe DB si possible,
AUTH_KEY,SECURE_AUTH_KEY, etc. danswp-config.php(invalidate sessions).
- Identifier le point d’entrée :
- diff récent (plugin custom, snippet, widget builder),
- logs HTTP corrélés aux erreurs DB,
- recherche de concat SQL.
- Nettoyage fichiers :
- réinstaller WordPress core depuis une source propre,
- réinstaller thèmes/plugins depuis des sources officielles,
- inspecter
wp-content/uploads(fichiers PHP, .phtml, .php5), - chercher des backdoors (fonctions obfusquées, base64, gzinflate) dans
wp-content.
- Nettoyage base :
- chercher des options suspectes (autoload énorme, scripts injectés),
- chercher des utilisateurs inconnus,
- chercher des liens spam dans posts/meta.
- Corriger la vulnérabilité (prepare + whitelist) et ajouter des garde-fous (capabilities, nonces, rate-limit).
- Vérifier l’intégrité :
- scan de malware (outil de confiance),
- comparaison de checksums si disponible,
- surveillance renforcée 72h (logs + alerting).
- Déclarer l’incident si nécessaire (RGPD si données personnelles exposées). Ne jouez pas au poker avec la conformité.
Piège fréquent : “on a patché le code, donc c’est bon”. Si l’attaquant a pu écrire une backdoor, le patch ne retire pas la porte dérobée. D’où l’ordre des étapes.
Conseils de maintenance et compatibilité
Sur WordPress 6.9.4, $wpdb reste l’outil de base pour les requêtes custom, mais vous gagnez souvent à réduire la surface SQL directe :
- Préférez WP_Query quand c’est possible : moins de SQL custom, moins de risques de concat.
- Encapsulez l’accès DB dans une classe (repository) au lieu d’éparpiller des
$wpdbdans des shortcodes, widgets et callbacks AJAX. - Injection de dépendances légère : passez
$wpdbau constructeur (ou via factory) pour tester vos requêtes en isolation.
Pattern “Repository” minimal (testable) avec $wpdb injecté
<?php
/**
* Repository de lecture : centralise les requêtes SQL et impose prepare().
*/
final class Books_Repository {
private wpdb $db;
public function __construct(wpdb $db) {
$this->db = $db;
}
/**
* @return array<object>
*/
public function search_by_author_login(string $author, string $order, int $limit): array {
$order = strtoupper(trim($order));
$order = in_array($order, array('ASC', 'DESC'), true) ? $order : 'DESC';
$limit = max(1, min(50, $limit));
$like = '%' . $this->db->esc_like(trim($author)) . '%';
$sql = $this->db->prepare(
"
SELECT p.ID, p.post_title
FROM {$this->db->posts} p
INNER JOIN {$this->db->users} u ON u.ID = p.post_author
WHERE p.post_type = %s
AND p.post_status = %s
AND u.user_login LIKE %s
ORDER BY p.post_date {$order}
LIMIT %d
",
'book',
'publish',
$like,
$limit
);
return $this->db->get_results($sql);
}
}
Impact performance/SEO : une SQLi n’est pas qu’un risque de fuite. Les attaques time-based et les requêtes pathologiques peuvent dégrader le TTFB, déclencher des 5xx, et faire chuter l’indexation. J’ai déjà vu des sites “propres” côté contenu perdre du trafic parce qu’un endpoint vulnérable se faisait marteler, saturant MySQL.
Compatibilité page builders : cache et rendu dynamique
- Si votre shortcode/module dépend de paramètres URL, attention au cache (Divi/Elementor/Avada + cache serveur). Vous pouvez servir un résultat “mixé” à d’autres visiteurs. Si c’est sensible, désactivez le cache sur cette page, ou rendez le filtrage côté AJAX avec nonce/capability.
- Si vous passez par AJAX, ne confondez pas “nonce présent” et “action autorisée”. Un nonce protège contre CSRF, pas contre un utilisateur malveillant qui appelle directement l’endpoint.
Ressources
- Documentation officielle : wpdb::prepare()
- Documentation officielle : wpdb::esc_like()
- developer.wordpress.org : sécurité (APIs et bonnes pratiques)
- REST API : endpoints custom + permission_callback
- WordPress.org : Hardening WordPress
- PHP.net : prepared statements (contexte général)
- WordPress Core Trac (suivi des changements et correctifs sécurité)
- GitHub : mirror wordpress-develop (historique et PR/commits)
FAQ
Est-ce que sanitize_text_field() suffit contre la SQLi ?
Non. Ça normalise une chaîne pour un usage “texte”, mais ça ne transforme pas une concaténation SQL en requête paramétrée. Vous pouvez (et devez) valider/sanitizer, puis utiliser $wpdb->prepare().
$wpdb->prepare() protège-t-il aussi ORDER BY et les noms de colonnes ?
Non. Les placeholders sont faits pour des valeurs. Un nom de colonne, une direction de tri, un fragment SQL : c’est de la structure. Pour ça, whitelist/mapping obligatoire.
Pourquoi utiliser wp_unslash() avant de traiter $_GET / $_POST ?
WordPress ajoute des slashes dans certains contextes historiques. Sur un site moderne, ça varie selon la provenance. Normaliser avec wp_unslash() évite des doubles échappements et des bugs subtils.
Comment gérer un LIKE correctement ?
Utilisez $wpdb->esc_like() sur la partie utilisateur, puis ajoutez vos % autour. Sinon, l’utilisateur peut injecter des jokers et vous déclenchez des scans coûteux.
Je peux mettre %d pour un LIMIT ?
Oui, et c’est recommandé. Ensuite, imposez une borne max côté PHP pour éviter le DoS. Le placeholder ne remplace pas une politique de limites.
Faut-il remplacer toutes les requêtes $wpdb par WP_Query ?
Pas forcément. WP_Query réduit le risque, mais certains besoins (reporting, agrégations) sont plus simples en SQL. L’objectif est de centraliser, préparer, whitelister, et tester.
Une SQLi est-elle possible via un shortcode utilisé par un éditeur ?
Oui. L’entrée “utilisateur” ne veut pas dire “visiteur anonyme” uniquement. Un compte éditeur, un import, un builder, ou un contenu synchronisé peut injecter des paramètres. Si le shortcode assemble du SQL, sécurisez-le pareil.
Quid des endpoints REST : je dois quand même préparer si j’ai permission_callback ?
Oui. La permission réduit l’exposition, mais ne garantit pas que l’entrée est sûre. Un compte autorisé peut être compromis, ou une faille ailleurs peut appeler l’endpoint. Préparez toujours.
Comment éviter que mon correctif casse le site (fatal error) ?
Travaillez en staging, lancez php -l sur vos fichiers, déployez via Git/CI. Et purgez les caches (page builder + serveur + CDN), sinon vous allez diagnostiquer des “fantômes”.
Si j’ai un WAF, je peux être plus souple sur le code ?
Non. Un WAF bloque surtout des signatures connues et du bruit. Une injection discrète ou un contournement passe. Le code doit être correct, point.
Quel est le signe le plus fiable d’une SQLi en cours ?
Souvent : corrélation entre des requêtes HTTP anormales (query strings longues, répétées) et des erreurs DB/latences MySQL. Les slow queries et les 500/502 sur des endpoints filtrés sont de bons signaux.