Risques de sécurité

Les bases de données contiennent souvent des données sensibles et permettent d'effectuer des opérations dangereuses. Pour travailler en sécurité avec Nette Database, il est essentiel de :

  • comprendre la différence entre une API sûre et une API non sûre
  • utiliser des requêtes paramétrées
  • valider correctement les données d'entrée

Qu'est-ce que l'injection SQL ?

L'injection SQL est le risque de sécurité le plus grave lors du travail avec les bases de données. Elle survient lorsqu'une entrée utilisateur non assainie devient partie d'une requête SQL. Un attaquant peut y insérer ses propres commandes SQL et ainsi :

  • obtenir un accès non autorisé aux données
  • modifier ou supprimer des données dans la base
  • contourner l'authentification
// ❌ CODE DANGEREUX - vulnérable à l'injection SQL
$database->query("SELECT * FROM users WHERE name = '$_GET[name]'");

// Un attaquant pourrait saisir une valeur comme : ' OR '1'='1
// La requête résultante serait : SELECT * FROM users WHERE name = '' OR '1'='1'
// Ce qui renvoie tous les utilisateurs

Il en va de même pour Database Explorer :

// ❌ CODE DANGEREUX - vulnérable à l'injection SQL
$table->where('name = ' . $_GET['name']);
$table->where("name = '$_GET[name]'");

Requêtes paramétrées

La défense fondamentale contre l'injection SQL, ce sont les requêtes paramétrées. Nette Database propose plusieurs façons de les utiliser.

La plus simple est d'utiliser des points d'interrogation comme placeholders :

// ✅ Requête paramétrée sûre
$database->query('SELECT * FROM users WHERE name = ?', $name);

// ✅ Condition sûre dans Explorer
$table->where('name = ?', $name);

Cela vaut pour toutes les autres méthodes de Database Explorer qui permettent d'insérer des expressions avec des points d'interrogation et des paramètres.

Pour les commandes INSERT, UPDATE ou la clause WHERE, nous pouvons passer les valeurs dans un tableau :

// ✅ INSERT sûr
$database->query('INSERT INTO users', [
	'name' => $name,
	'email' => $email,
]);

// ✅ INSERT sûr dans Explorer
$table->insert([
	'name' => $name,
	'email' => $email,
]);

Validation des valeurs des paramètres

Les requêtes paramétrées sont la pierre angulaire d'un travail sûr avec la base de données. Les valeurs que nous y insérons doivent cependant passer par plusieurs niveaux de contrôle :

Contrôle du type

Le plus important est de garantir le bon type de données des paramètres : c'est une condition nécessaire à l'utilisation sûre de Nette Database. La base de données part du principe que toutes les données d'entrée ont le type de données correct, correspondant à la colonne concernée.

Si par exemple $name, dans les exemples précédents, était de façon inattendue un tableau au lieu d'une chaîne, Nette Database essaierait d'insérer tous ses éléments dans la requête SQL, ce qui provoquerait une erreur. N'utilisez donc jamais des données non validées provenant de $_GET, $_POST ou $_COOKIE directement dans les requêtes de base de données.

Validation du format

Au deuxième niveau, nous contrôlons le format des données : par exemple si les chaînes sont bien encodées en UTF-8 et si leur longueur correspond à la définition de la colonne, ou si les valeurs numériques se situent dans la plage autorisée par le type de données de la colonne.

Pour ce niveau de validation, nous pouvons en partie nous appuyer sur la base de données elle-même : beaucoup de bases refuseront les données invalides. Le comportement peut cependant varier ; certaines tronqueront silencieusement les chaînes trop longues ou rogneront les nombres hors plage.

Validation propre au domaine

Le troisième niveau concerne les contrôles logiques propres à votre application. Par exemple vérifier que les valeurs issues des listes déroulantes correspondent bien aux options proposées, que les nombres se situent dans la plage attendue (par exemple un âge de 0 à 150 ans), ou que les dépendances mutuelles entre valeurs ont un sens.

Méthodes de validation recommandées

  • Utilisez Nette Forms, qui assure automatiquement la validation correcte de toutes les entrées.
  • Utilisez les presenters et indiquez les types de données des paramètres dans les méthodes action*() et render*().
  • Ou implémentez votre propre couche de validation à l'aide des outils PHP standards comme filter_var().

Travailler en sécurité avec les colonnes

Dans la section précédente, nous avons montré comment valider correctement les valeurs des paramètres. Lorsque nous utilisons des tableaux dans les requêtes SQL, nous devons cependant accorder la même attention à leurs clés.

// ❌ CODE DANGEREUX - les clés du tableau ne sont pas assainies
$database->query('INSERT INTO users', $_POST);

Pour les commandes INSERT et UPDATE, c'est une faille de sécurité critique : un attaquant peut insérer ou modifier n'importe quelle colonne de la base. Il pourrait par exemple définir is_admin = 1 ou insérer des données arbitraires dans des colonnes sensibles (ce qu'on appelle la Mass Assignment Vulnerability).

Dans les conditions WHERE, c'est encore plus dangereux, car elles peuvent contenir des opérateurs :

// ❌ CODE DANGEREUX - les clés du tableau ne sont pas assainies
$_POST['salary >'] = 100000;
$database->query('SELECT * FROM users WHERE', $_POST);
// exécute la requête WHERE (`salary` > 100000)

Un attaquant peut ainsi découvrir méthodiquement les salaires des employés. Il peut commencer par exemple par une requête sur les salaires supérieurs à 100 000, puis inférieurs à 50 000, et en resserrant progressivement la fourchette, il peut révéler les salaires approximatifs de tous les employés. Ce type d'attaque s'appelle l'énumération SQL.

Les méthodes where() et whereOr() sont même bien plus souples et acceptent des expressions SQL, opérateurs et fonctions compris, dans les clés comme dans les valeurs. Cela donne à un attaquant la possibilité d'effectuer une injection SQL :

// ❌ CODE DANGEREUX - l'attaquant peut insérer son propre SQL
$_POST = ['0) UNION SELECT name, salary FROM users WHERE (1'];
$table->where($_POST);
// exécute la requête WHERE (0) UNION SELECT name, salary FROM users WHERE (1)

Cette attaque termine la condition d'origine par 0), ajoute son propre SELECT à l'aide d'UNION pour obtenir des données sensibles de la table users, puis referme la requête de façon syntaxiquement correcte avec WHERE (1).

Liste blanche de colonnes

Pour travailler en sécurité avec les noms de colonnes, nous avons besoin d'un mécanisme garantissant que l'utilisateur ne peut travailler qu'avec les colonnes autorisées et ne peut pas en ajouter d'autres. Nous pourrions essayer de détecter et de bloquer les noms de colonnes dangereux (liste noire), mais cette approche n'est pas fiable : un attaquant trouvera toujours une nouvelle façon d'écrire un nom de colonne dangereux à laquelle nous n'aurions pas pensé.

Il est donc bien plus sûr d'inverser la logique et de définir une liste explicite des colonnes autorisées (liste blanche) :

// Colonnes que l'utilisateur a le droit de modifier
$allowedColumns = ['name', 'email', 'active'];

// Supprime de l'entrée toutes les colonnes non autorisées
$filteredData = array_intersect_key($userData, array_flip($allowedColumns));

// ✅ Utilisation désormais sûre dans les requêtes, par exemple :
$database->query('INSERT INTO users', $filteredData);
$table->update($filteredData);
$table->where($filteredData);

Identifiants dynamiques

Pour les noms dynamiques de tables et de colonnes, utilisez le placeholder ?name. Il assure l'échappement correct des identifiants selon la syntaxe de la base concernée (par exemple à l'aide de backticks dans MySQL) :

// ✅ Usage sûr d'identifiants de confiance
$table = 'users';
$column = 'name';
$database->query('SELECT ?name FROM ?name', $column, $table);
// Résultat dans MySQL : SELECT `name` FROM `users`

Important : n'utilisez le symbole ?name que pour des valeurs de confiance définies dans le code de l'application. Pour les valeurs venant de l'utilisateur, utilisez de nouveau une liste blanche. Sinon, vous vous exposez à des risques de sécurité :

// ❌ DANGEREUX - n'utilisez jamais l'entrée de l'utilisateur
$database->query('SELECT ?name FROM users', $_GET['column']);
version: 4.x