J'avais l'habitude de penser que rédiger ma propre couche d'authentification était une marque de fierté. Si l'on comprend les JWT et que l'on sait hasher un mot de passe, à quel point cela peut-il être difficile ? J'ai mis en place un backend Node, émis des jetons, et j'ai considéré que c'était fait. Le code fonctionnait. Les tests passaient. Puis j'ai commencé à lire comment les attaques réelles se produisent, et le sol s'est dérobé sous mes pieds. Chaque chapitre sur l'injection, l'énumération et les fuites par canal auxiliaire me renvoyait vers mon éditeur avec un sentiment de malaise. Mon système d'authentification n'avait pas seulement des lacunes ; il avait quatre portes grandes ouvertes que j'avais moi-même installées. Voici ce que j'ai découvert, et exactement ce que j'ai changé.
Injection SQL par concaténation de chaînes
Le premier bug était la plus vieille technique au monde. Je prenais l'entrée de l'utilisateur et je l'insérais directement dans des chaînes SQL. Dans ma route de connexion, je récupérais l'e-mail du corps de la requête et je le concaténais dans une requête du type SELECT * FROM users WHERE email = '${email}'. Cela me semblait inoffensif parce que je contrôlais le frontend. C'est une supposition dangereuse. Un attaquant n'a pas besoin de votre frontend. Un seul corps de requête POST forgé pourrait transformer cette vérification de connexion en une fuite de données ou en une base de données effacée. Envoyez une charge utile comme ' OR '1'='1 ou, pire, une requête empilée qui supprime des tables, et si la chaîne est exécutée brute, vos données sont perdues. Je n'avais donné à la base de données aucun moyen de distinguer mon code des données de l'attaquant.
La solution n'était pas d'ajouter plus de validation d'entrée ou d'échapper les chaînes à la main. La véritable solution résidait dans les requêtes paramétrées. Je suis passé à node-postgres et j'ai commencé à utiliser des espaces réservés comme $1. La requête devient un modèle : SELECT * FROM users WHERE email = $1. Le pilote envoie le SQL et les valeurs via des canaux distincts. La base de données traite l'entrée strictement comme une donnée, quels que soient les caractères qu'elle contient. Ce seul changement ferme toute la classe des attaques par injection. C'est plus simple à lire, plus facile à maintenir, et cela vous évite d'avoir à être un magicien des expressions régulières chaque fois que vous écrivez une clause WHERE.
Énumération d'e-mails via les messages d'erreur
Ma deuxième erreur ressemblait à une bonne UX. Lorsqu'un utilisateur saisissait le mauvais e-mail, je renvoyais User not found. Lorsqu'il saisissait le bon e-mail mais le mauvais mot de passe, je renvoyais Incorrect password. Cela semblait utile. C'était aussi un outil de reconnaissance pour les attaquants. Des scripts d'énumération peuvent bombarder votre point de terminaison de connexion avec des milliers d'adresses e-mail. Si le corps de la réponse ou le code d'état change en fonction de l'existence ou non du compte, le script peut construire une liste vérifiée de vos utilisateurs. Cette liste devient la base du credential stuffing, du phishing ciblé et d'autres tentatives de force brute.
J'ai dû accepter que l'ergonomie doive parfois céder le pas à la sécurité. J'ai modifié chaque chemin de connexion échoué pour qu'il renvoie exactement la même chaîne : Invalid credentials. Aucun indice. Aucune logique de branchement dans la réponse d'erreur. Que l'e-mail soit manquant, que le mot de passe soit incorrect ou que le compte soit verrouillé, le texte reste identique. Cela s'applique également aux flux d'inscription et de réinitialisation de mot de passe ; ne révélez pas si une adresse est déjà présente dans votre système. Un message générique supprime une fuite d'informations dont dépendent les attaquants.
Attaques temporelles lors de la comparaison de mots de passe
Le troisième bug était invisible. J'étais
