Aller au contenu principal

Concept #012

Sécurité

L'Injection SQL

#0127 min de lecture
  • sécurité
  • base-de-données
  • programmation
Injection SQL : quand la base de données fait confiance aveuglément

L'injection SQL figure au TOP 1 de l'OWASP depuis sa création. Pourtant, des décennies après sa découverte, elle reste l'une des failles les plus exploitées sur le web. Le principe est d'une simplicité déconcertante : si votre application construit ses requêtes SQL en collant des chaînes de caractères, un attaquant peut y glisser du SQL supplémentaire. Votre base de données l'exécutera sans sourciller — elle ne fait pas la différence entre votre code et l'entrée de l'utilisateur.

Le principe de l'injection SQL

Tout part d'une erreur de conception fondamentale : mélanger les données et le code. Quand vous concaténez directement une valeur utilisateur dans une requête SQL, vous permettez à cette valeur de modifier la structure de la requête elle-même.

Prenons un exemple concret. Un formulaire de connexion reçoit un nom d'utilisateur et un mot de passe, puis exécute cette requête :

SELECT * FROM users WHERE username = 'alice' AND password = 'secret123';

Ça semble raisonnable. Mais regardons comment cette requête est construite côté serveur :

// Code vulnérable — ne jamais faire ça
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;

La requête est une simple chaîne de caractères. Tout ce que l'utilisateur saisit y est collé mot pour mot. La base de données reçoit la chaîne finale et l'interprète comme du SQL valide — sans distinction entre ce que vous avez écrit et ce que l'utilisateur a fourni.

Exemple d'attaque : contourner une connexion

Un attaquant n'a pas besoin du mot de passe. Il lui suffit de manipuler la logique de la requête avec une entrée soigneusement construite.

Entrée de l'attaquant dans le champ "nom d'utilisateur" :

' OR 1=1 --

Résultat après concaténation :

SELECT * FROM users WHERE username = '' OR 1=1 --' AND password = 'nimporte';

Décomposons ce qui se passe :

  • ' ferme prématurément la chaîne ouverte par le développeur
  • OR 1=1 ajoute une condition toujours vraie — la requête retourne tous les utilisateurs
  • -- est un commentaire SQL : tout ce qui suit est ignoré, y compris la vérification du mot de passe

La requête retourne le premier utilisateur de la base — souvent l'administrateur. L'attaquant est connecté sans connaître aucun mot de passe.

Aller plus loin : exfiltration de données

L'injection ne sert pas qu'à contourner une authentification. Avec la technique UNION, un attaquant peut lire n'importe quelle table :

-- Entrée : ' UNION SELECT username, password, null FROM users --
SELECT id, name, email FROM products WHERE id = '' UNION SELECT username, password, null FROM users --'

La requête retourne maintenant les noms d'utilisateur et mots de passe de tous les comptes. Avec les bons droits, un attaquant peut également modifier, supprimer des données, ou même exécuter des commandes système via des fonctions SQL comme xp_cmdshell (SQL Server).

Comment se protéger

Les requêtes paramétrées (préparées)

C'est la solution principale, la seule vraiment fiable. L'idée est de séparer structurellement le code SQL des données. Vous envoyez la requête d'abord, avec des emplacements (? ou $1, $2...), puis les valeurs séparément. Le driver de base de données se charge de les assembler en toute sécurité — les valeurs ne peuvent jamais modifier la structure de la requête.

// Avec le driver natif pg (PostgreSQL)
import { Pool } from "pg";
 
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
 
// Vulnérable : concaténation directe
async function loginVulnerable(username: string, password: string) {
  const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
  const result = await pool.query(query);
  return result.rows[0] ?? null;
}
 
// Sécurisé : requête paramétrée
async function loginSafe(username: string, password: string) {
  const query = "SELECT * FROM users WHERE username = $1 AND password = $2";
  const result = await pool.query(query, [username, password]);
  return result.rows[0] ?? null;
}

Avec la version sécurisée, si l'attaquant saisit ' OR 1=1 --, cette chaîne entière est traitée comme une valeur littérale dans la colonne username. La base de données cherche un utilisateur dont le nom est exactement ' OR 1=1 -- — et n'en trouve aucun. La structure SQL n'est jamais altérée.

Avec un query builder ou un ORM

Les ORMs modernes utilisent les requêtes paramétrées en interne. Passer par eux, c'est bénéficier de cette protection automatiquement, sans y penser.

import { PrismaClient } from "@prisma/client";
 
const prisma = new PrismaClient();
 
// Prisma génère des requêtes paramétrées automatiquement
async function loginWithPrisma(username: string, password: string) {
  const user = await prisma.user.findFirst({
    where: {
      username: username,
      password: password,
    },
  });
  return user;
}

Prisma, TypeORM, Sequelize, Drizzle — ils gèrent tous ce cas. La requête que vous écrivez en TypeScript est traduite en SQL paramétré. Vos données ne touchent jamais directement la chaîne SQL.

Attention cependant : certains ORMs permettent d'injecter du SQL brut via des méthodes raw() ou query(). Ces échappatoires brisent la protection — traitez-les comme de la concaténation ordinaire et paramétrez-les explicitement.

// Dangereux : SQL brut avec interpolation
await prisma.$queryRaw`SELECT * FROM users WHERE username = '${username}'`;
 
// Sécurisé : SQL brut avec paramètre typé Prisma
import { Prisma } from "@prisma/client";
await prisma.$queryRaw`SELECT * FROM users WHERE username = ${username}`;
// Prisma détecte le template literal et paramètre automatiquement les variables interpolées

Le principe de moindre privilège

En complément des requêtes paramétrées, limitez les droits du compte de base de données utilisé par votre application. Un compte qui ne peut faire que SELECT, INSERT et UPDATE ne pourra pas exécuter DROP TABLE ou lire d'autres bases — même en cas d'injection réussie.

-- Créer un utilisateur applicatif avec des droits limités
CREATE USER app_user WITH PASSWORD 'strong_password';
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app_user;
-- Pas de DROP, pas de DELETE en masse, pas d'accès aux tables système

Position dans le TOP 10 OWASP

L'injection SQL appartient à la catégorie A03:2021 - Injection du classement OWASP Top 10. Ce groupe rassemble toutes les injections (SQL, NoSQL, LDAP, OS commands...) sous un même concept : faire interpréter des données non fiables comme du code ou des commandes.

L'injection SQL en particulier a occupé la première place du classement OWASP pendant plus de dix ans — de 2010 à 2017. Elle reste dans le top 3 malgré la généralisation des ORMs, car de nombreuses applications continuent de mélanger SQL et entrées utilisateurs, notamment dans les systèmes legacy ou les requêtes dynamiques complexes.

Le récapitulatif

TechniqueProtectionFiabilité
Concaténation de chaînesAucuneVulnérable
Échappement manuelPartielle — erreurs fréquentesInsuffisante
Requêtes paramétréesSéparation données/codeSolide
ORM (sans raw SQL)Paramétrisation automatiqueSolide
ORM + moindre privilègeDouble coucheOptimale

La règle d'or : une valeur utilisateur ne doit jamais être interpolée directement dans du SQL. Peu importe le langage, peu importe le framework — si vous collez une chaîne dans une requête, vous prenez un risque. Les requêtes paramétrées ne sont pas une optimisation ou une bonne pratique optionnelle : elles sont la frontière entre un code sûr et une faille béante dans votre base de données.