# $wpdb->prepare et injections SQL : les pièges que même les pros font encore

> LIKE, IN(), types de placeholders mal choisis : $wpdb->prepare protège des injections SQL seulement s'il est utilisé correctement. Voici les erreurs classiques.

- Auteur : Clément Hadrot
- Publié le : 2021-01-14
- Mis à jour le : 2021-01-14
- Catégorie : Sécurité
- URL : https://wpmoderne.dev.wordpress-developpement.fr/securite/wpdb-prepare-injections-sql/

## L’essentiel

- Une requête SQL avec une variable concaténée directement est une faille potentielle
- Les placeholders %s, %d et %f ne sont pas interchangeables
- LIKE et IN() demandent une construction particulière du prepare

Les injections SQL restent, des années après leur découverte, l'une des vulnérabilités les plus graves qu'une extension ou un thème WordPress puisse introduire. Une requête mal construite peut permettre à un attaquant de lire l'intégralité de la base de données, y compris la table des utilisateurs et leurs mots de passe hashés, ou de la modifier directement. WordPress fournit un outil simple pour s'en prémunir, `$wpdb->prepare()`, mais son usage comporte des pièges que je retrouve régulièrement en audit de code, y compris chez des développeurs expérimentés.

Le principe de base est connu : ne jamais insérer une variable directement dans une chaîne SQL. Ce qui est moins connu, ce sont les cas particuliers où `prepare()` est mal utilisé tout en donnant l'illusion d'être sécurisé.

## Le problème de base : la concaténation directe

Voici l'exemple classique de code vulnérable, du genre qu'on retrouve encore dans certaines extensions anciennes :

```
// Dangereux : ne jamais faire ça
$id = $_GET['id'];
$resultats = $wpdb->get_results(
    "SELECT * FROM {$wpdb->prefix}mes_donnees WHERE id = $id"
);
```

Si `$id` contient autre chose qu'un nombre, par exemple `1 OR 1=1`, la requête change complètement de sens et peut renvoyer l'intégralité de la table. La correction consiste à passer par `$wpdb->prepare()`, qui échappe et type correctement chaque valeur :

```
$id = absint( $_GET['id'] );
$resultats = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}mes_donnees WHERE id = %d",
        $id
    )
);
```

Notez que le préfixe de table, `{$wpdb->prefix}`, n'a pas besoin d'être préparé puisqu'il ne provient jamais d'une entrée utilisateur ; seules les valeurs variables issues d'une requête, d'un formulaire ou d'un cookie doivent transiter par `prepare()`.

## Bien choisir son placeholder

`$wpdb->prepare()` accepte trois types de placeholders, et se tromper de type est une source fréquente de bugs, parfois de failles :

- `%d` pour un entier, converti automatiquement en nombre entier même si la valeur d'origine était une chaîne ;
- `%f` pour un nombre à virgule flottante ;
- `%s` pour une chaîne de caractères, la plus utilisée et la plus sûre par défaut car elle échappe correctement les caractères spéciaux SQL.

Utiliser `%s` pour un identifiant numérique fonctionne, mais utiliser `%d` pour un texte le tronque silencieusement à zéro ou à la première suite de chiffres trouvée, ce qui casse la requête sans erreur visible. En cas de doute sur le type réel d'une donnée, `%s` reste le choix le plus sûr.

> L'essentiel à retenir : Une requête SQL avec une variable concaténée directement est une faille potentielle ; Les placeholders %s, %d et %f ne sont pas interchangeables ; LIKE et IN() demandent une construction particulière du prepare

## Le piège du LIKE

Construire une clause `LIKE` en insérant directement des caractères `%` dans la valeur avant de la passer à `prepare()` casse la préparation, car `prepare()` utilise lui-même le caractère `%` comme marqueur de placeholder. La bonne méthode utilise `$wpdb->esc_like()` avant de construire le motif :

```
$recherche = $wpdb->esc_like( $_GET['s'] );
$motif = '%' . $recherche . '%';

$resultats = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
        $motif
    )
);
```

`esc_like()` échappe les caractères `%` et `_` présents dans la valeur de recherche elle-même, avant que le `%` de concaténation ne soit ajouté pour construire le motif de recherche. Sans cette étape, un utilisateur qui saisit un `%` ou un `_` dans le champ de recherche obtient un comportement inattendu, voire une requête cassée.

## Le piège du IN()

Une clause `IN()` avec un nombre variable de valeurs ne peut pas recevoir un seul placeholder pour toute la liste. Il faut générer autant de placeholders que de valeurs, puis les passer un par un :

```
$ids = array( 4, 15, 23 );
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );

$resultats = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE ID IN ($placeholders)",
        $ids
    )
);
```

La fonction `array_fill()` génère ici un placeholder `%d` par élément du tableau, puis `prepare()` accepte un tableau comme second argument, qu'elle éclate automatiquement pour remplir chaque placeholder dans l'ordre.

## Erreurs fréquentes en revue de code

- Appeler `prepare()` mais ne jamais utiliser son résultat dans la requête réellement exécutée ;
- Mélanger des valeurs déjà échappées manuellement avec `esc_sql()` et un appel à `prepare()`, ce qui double l'échappement et corrompt les données ;
- Passer un tableau ou un objet directement en paramètre sans le sérialiser, provoquant une erreur silencieuse ou un comportement inattendu ;
- Oublier que les noms de colonnes et de tables ne peuvent pas être des placeholders : ils doivent être validés autrement, par exemple via une liste blanche de valeurs autorisées.

> En revue de code, dès que je vois une requête SQL construite avec une double quote contenant une variable PHP directement entre accolades, je m'arrête et je vérifie systématiquement si un `prepare()` a été utilisé plus loin. C'est souvent là que se cachent les failles les plus sérieuses d'une extension.

## En résumé

`$wpdb->prepare()` reste l'outil de référence pour se prémunir des injections SQL dans WordPress, mais il ne protège que s'il est utilisé pour chaque valeur variable, avec le bon type de placeholder, et avec les précautions particulières qu'exigent `LIKE` et `IN()`. Une requête SQL construite à la main sans passer par `prepare()` doit systématiquement déclencher un signal d'alarme en revue de code, quelle que soit la confiance qu'on accorde à l'origine de la donnée.
