CodeAuditAgent
Tous les articles
  • Sécurité
  • SQL
  • OWASP

Prévenir l’injection SQL en Node.js, Python et Go

Extraits SQL vulnérables et corrigés pour pg, Prisma, psycopg, SQLAlchemy et database/sql de Go, plus ORDER BY dynamiques et listes IN sûrs. CWE-89.

· 7 min de lecture · Lina Source LLC

L’injection SQL (CWE-89) est l’un des plus anciens bugs du web et reste l’un des plus dévastateurs. Sa cause n’a jamais changé : une entrée utilisateur est collée dans le texte d’une requête, si bien que la base de données ne peut plus distinguer les données du code. Le correctif n’a pas changé non plus : envoyez la requête et les valeurs séparément, et laissez le driver les lier.

Ce qui a changé, c’est l’endroit où le bug se loge. La plupart des équipes utilisent un ORM pour les requêtes courantes, si bien que l’injection apparaît désormais dans les échappatoires : la requête brute écrite pour un rapport, l’endpoint de recherche avec un tri dynamique, le script de migration, l’outil d’administration. Ce guide présente la version vulnérable et la version corrigée dans trois écosystèmes, puis traite les deux cas que les paramètres seuls ne résolvent pas.

L’impact se limite rarement à une seule table. Une seule requête injectable s’exécute généralement avec l’ensemble des privilèges de l’application sur la base : elle peut lire les données de tous les clients, y compris les hachages de mots de passe et les jetons d’API, modifier des lignes et, sur certaines bases, atteindre le système de fichiers ou d’autres serveurs. Les techniques à l’aveugle extraient les données bit par bit même quand le résultat de la requête n’est jamais montré à l’utilisateur : un endpoint qui ne renvoie que vrai ou faux reste donc exploitable.

Pourquoi les paramètres fonctionnent et l’échappement non

Avec une requête paramétrée, le driver envoie le texte SQL avec des placeholders, et les valeurs voyagent comme des données distinctes. La base analyse l’instruction avant même de voir la valeur : un guillemet ou un point-virgule dans l’entrée n’est qu’un caractère dans une chaîne. L’échappement manuel tente de rendre une valeur sûre pour l’insérer dans le texte SQL, et il échoue sur les encodages, les contextes numériques, les identifiants et tous les cas limites auxquels vous n’avez pas pensé. N’échappez pas : liez.

Node.js : pg et Prisma

Avec node-postgres, le motif vulnérable est un template literal passé à query. Le correctif est un placeholder $1 accompagné d’un tableau de valeurs. Prisma est sûr par défaut, mais il propose deux API brutes, et une seule d’entre elles est sûre avec l’interpolation.

import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";

const pool = new Pool();
const prisma = new PrismaClient();

// Vulnerable: input becomes part of the SQL text
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);

// Fixed: value is bound as a parameter
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);

// Prisma: $queryRaw is a tagged template, interpolations become parameters
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;

// Prisma: $queryRawUnsafe with concatenation is injectable
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = '" + email + "'"
);

// If you must use $queryRawUnsafe, pass values as arguments
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = $1",
  email
);

Un piège subtil de Prisma : construire d’abord la chaîne de requête puis la passer à $queryRaw fait perdre la protection. La sécurité vient de la syntaxe de tagged template. Si vous devez composer des fragments, utilisez Prisma.sql et Prisma.join, qui conservent les valeurs sous forme de paramètres. La même règle vaut pour les autres bibliothèques à tagged templates comme postgres.js et slonik : c’est le tag qui rend l’interpolation sûre, donc une chaîne de requête assemblée au préalable et passée comme simple valeur le contourne.

Python : psycopg et SQLAlchemy

En Python, les outils dangereux sont les f-strings, l’opérateur % et str.format appliqués au SQL. psycopg utilise des placeholders %s, qui ressemblent à du formatage de chaîne mais n’en sont pas : les valeurs passent dans le second argument de execute, jamais par l’opérateur %. La construction text() de SQLAlchemy est sûre avec des paramètres liés nommés, et dangereuse si vous formatez les valeurs dans la chaîne.

Un détail de psycopg piège souvent les développeurs : le placeholder est %s (ou %(name)s pour les valeurs nommées) quel que soit le type de colonne, et on ne l’entoure jamais de guillemets. Écrire '%s' entre guillemets retransforme le paramètre en partie d’un littéral de chaîne et casse la requête. Il en va de même pour les paramètres nommés de SQLAlchemy : :status, et non ':status'.

from sqlalchemy import text

# psycopg: vulnerable
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")

# psycopg: fixed, values passed separately
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))

# SQLAlchemy text(): vulnerable
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))

# SQLAlchemy text(): fixed with a bind parameter
conn.execute(
    text("SELECT id FROM orders WHERE status = :status"),
    {"status": status},
)

Go : database/sql

Le package database/sql de Go prend en charge nativement les placeholders, mais leur syntaxe dépend du driver : $1 pour les drivers PostgreSQL comme pgx, ? pour MySQL et SQLite. Le motif vulnérable consiste à construire la clause WHERE avec fmt.Sprintf ou par concaténation de chaînes. Go étant typé statiquement, il est tentant de supposer qu’un paramètre int ne peut pas être dangereux. C’est vrai pour la valeur elle-même, mais l’habitude de construire les requêtes avec Sprintf se propage aux paramètres de type chaîne voisins : gardez donc toutes vos requêtes sur des placeholders.

// Vulnerable: string concatenation
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)

// Fixed: placeholder plus argument (PostgreSQL syntax; MySQL uses ?)
var u User
err = db.QueryRowContext(ctx,
    "SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
    // not found
}

Les échappatoires des ORM

Les ORM protègent le query builder, pas chacune de leurs méthodes. Voici les endroits à examiner en premier dans n’importe quelle base de code :

  • Prisma : $queryRawUnsafe et $executeRawUnsafe, ainsi que tout appel à $queryRaw qui reçoit une chaîne préconstruite au lieu d’un tagged template.
  • Sequelize et TypeORM : sequelize.query, les appels .where() du query builder auxquels on passe une chaîne concaténée, et les clauses order ou group brutes.
  • Knex : knex.raw et whereRaw avec des valeurs interpolées au lieu de liaisons ?.
  • SQLAlchemy : text() avec des f-strings, et literal_column() ou des noms de colonnes issus de l’entrée utilisateur.
  • Django : .raw(), .extra() et cursor.execute avec des chaînes formatées.
  • GORM : Where, Order et Raw appelés avec le résultat de fmt.Sprintf au lieu d’arguments.

ORDER BY dynamique : les paramètres n’y peuvent rien

Les placeholders lient des valeurs, pas des identifiants. Vous ne pouvez pas écrire ORDER BY $1 et passer un nom de colonne : la base trierait par une constante. Un tableau triable pousse donc souvent les développeurs à revenir à la construction de chaînes, et c’est là que l’injection réapparaît. La même limite s’applique aux noms de tables, aux noms de schémas et aux mots-clés SQL comme ASC et DESC.

Le motif sûr est une liste d’autorisation qui associe les clés de tri exposées à l’utilisateur à des noms de colonnes connus. L’entrée sélectionne une entrée de la table ; elle ne devient jamais du SQL. Le sens du tri reçoit le même traitement : il est ramené à un ASC ou DESC fixe.

var sortColumns = map[string]string{
    "created": "created_at",
    "name":    "name",
    "price":   "price_cents",
}

col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
    col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
    dir = "DESC"
}

// Safe: col and dir can only be values from the code above
query := fmt.Sprintf(
    "SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
    col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)

Si vous avez réellement besoin d’un identifiant dynamique impossible à placer dans une liste d’autorisation, utilisez l’échappement d’identifiants fourni par le driver plutôt que le vôtre. Avec psycopg, c’est sql.SQL(...).format(sql.Identifier(name)) ; avec pgx, pgx.Identifier{name}.Sanitize(). Une liste d’autorisation reste préférable, car l’échappement rend le nom sûr, mais pas nécessairement valide ni autorisé. Un identifiant échappé permet toujours à l’appelant de trier ou de filtrer sur n’importe quelle colonne de la table, y compris celles que vous n’aviez jamais l’intention d’exposer, comme un hachage de mot de passe ou un score interne. Trier sur une colonne cachée peut faire fuiter ses valeurs à travers l’ordre des résultats, une comparaison à la fois.

Des listes IN sans construction de chaînes

Filtrer sur une liste d’identifiants est l’autre excuse fréquente pour concaténer. Chaque stack majeure offre un moyen sûr de le faire :

  • PostgreSQL avec pg ou pgx : passez toute la liste comme un seul paramètre de type tableau et écrivez WHERE id = ANY($1). Le driver l’envoie sous forme de tableau Postgres.
  • Prisma : Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` se développe en un placeholder par élément.
  • SQLAlchemy : text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), puis passez une liste.
  • Go avec MySQL : générez la chaîne de placeholders à partir de la longueur de la liste (strings.Repeat("?,", n) sans la virgule finale) et passez les valeurs en arguments. Seuls les placeholders sont générés, jamais les valeurs.
  • Rejetez les listes vides avant l’exécution de la requête, et plafonnez la taille de la liste pour que l’endpoint ne puisse pas servir à construire des instructions gigantesques.

Défense en profondeur

La paramétrisation est le correctif. Les mesures suivantes réduisent les dégâts lorsqu’une requête passe entre les mailles du filet :

  • Connectez-vous avec un rôle de base de données qui ne dispose que des privilèges nécessaires à l’application. L’application web a rarement besoin de DROP, d’ALTER ou d’un accès à d’autres schémas.
  • Ne renvoyez pas les erreurs brutes de la base aux clients. L’injection basée sur les erreurs repose sur leur lecture.
  • Validez les types à la frontière : un identifiant qui doit être un entier ou un UUID doit être analysé comme tel avant d’atteindre la couche de données.
  • Définissez un statement timeout, qui limite aussi bien l’injection à l’aveugle basée sur le temps que les requêtes qui s’emballent.

Trouver celles que vous avez déjà

Commencez par rechercher les échappatoires listées plus haut, puis les mots-clés SQL proches d’un formatage de chaîne : SELECT ou WHERE dans des template literals, des f-strings, des appels à Sprintf ou des concaténations avec +. Chaque occurrence est soit correctement liée, soit construite à partir d’une liste d’autorisation, soit un bug. Ne négligez pas les chemins de code qui semblent internes : les imports CSV, les tâches cron et les tableaux de bord d’administration reçoivent souvent des entrées qui proviennent à l’origine d’un utilisateur, à un seul intermédiaire près. Des valeurs relues depuis votre propre base peuvent contenir une charge utile stockée sans danger plus tôt puis collée plus tard dans une requête : c’est ce qu’on appelle l’injection de second ordre. CodeAuditAgent effectue ce traçage à travers les fichiers pour les dépôts GitHub publics et le code collé, et signale les vulnérabilités CWE-89 avec la requête citée, le chemin par lequel l’entrée l’atteint et une version corrigée.

La règle à retenir est courte : les valeurs passent par des paramètres, les identifiants viennent d’une liste d’autorisation, et rien de ce qui provient de la requête n’est jamais collé dans le texte SQL.