node failles bonnes pratiques sécurité

Encore un article sur la sécurité de Node.js ? Mais dans celui-là, nous nous concentrons sur les failles rencontrées le plus fréquemment lors de tests d’intrusion.

Les vulnérabilités liées à Node ont des conséquences sur l’ensemble de votre application web. Il est donc essentiel de les détecter et de les corriger.  Certaines de ces failles ne sont pas spécifiques à Node et existent également dans d’autres langages et frameworks. C’est pour cela que nous nous sommes attachés à citer des bonnes pratiques générales et des outils propres à Node.js.

Entrons dans le vif du sujet.

Se protéger des failles Cross-Site-Scripting

Les failles Cross-Site-Scripting (XSS) sont l’exemple typique d’une vulnérabilité très connue, mais encore très souvent trouvée lors d’un test d’intrusion d’une application web.

Une faille Cross-Site-Scripting permet d’injecter du contenu dans une page. Pour s’en protéger, il est indispensable d’encoder les données provenant des utilisateurs. Vous pouvez échapper les données en les convertissant en HTML, en Unicode… Des modules existent pour cela, comme escape-html or node-esapi.

Il est également intéressant de flagger les cookies avec httpOnly de manière à ce que le client JS ne puisse pas y accéder.

response.setHeader('Set-Cookie', 'cookieName=cookieValue; HttpOnly');

De plus, des règles de scripts CSP (Content Security Policy) sont à définir. Elles permettent d’autoriser le contenu selon une liste que vous décidez et donc d’interdire de charger des types de contenus spécifiques.

Si jamais il y a une XSS, utiliser les CSP permet de restreindre ce qu’un attaquant peut faire.

Vous pouvez ainsi définir si des scripts externes peuvent être chargés, si des scripts inline peuvent être exécutés, si des ressources peuvent être chargées depuis la même origine que la page principale, etc…

response.setHeader("Content-Security-Policy", "script-src 'self' https://apis.google.com");

Prévenir les injections SQL

De même que les failles XSS, les vulnérabilités SQLi sont bien connues (elles sont discutées depuis plus de 20 ans -premier article public en 1998-). Nous en trouvons encore fréquemment lors de pentests.

Il s’agit d’ajouter une requête non prévue à une requête SQL pour interagir avec la base de données. Une faille SQLi permet de voler ou modifier des données, voire exécuter du code à distance. Vous pouvez en apprendre plus sur cette faille avec notre article dédié aux injections SQL.

Pour s’en protéger, il y a plusieurs options.

Le principe essentiel de défense est d’utiliser des requêtes préparées et pré-compilées, aussi appelées parametrised queries.  Il s’agit de définir en amont une requête avec des paramètres dans la requête au lieu de valeurs constantes.

Plusieurs ORM et query-builders permettent de faire des requêtes préparées.

Exemple avec Knex :

knex('users').where({
  first_name: 'Test',
  last_name:  'User'
}).select('id')

Actuellement, la recommandation est d’utiliser un ORM (Object Relational Mapping), c’est-à-dire un ensemble de libraires qui interagissent avec les données via des objets. Avec un ORM, il n’y a plus besoin de composer des requêtes SQL directement. Cela permet d’éviter les injections lorsqu’il est bien utilisé et maintenu à jour (n’oubliez pas de le patcher si une faille est découverte).

Sequelize est le principal ORM. Il existe aussi des query-builders, qui sont des versions plus « simples » des ORM.  L’un des plus utilisés est Knex.

Attention cependant à ne pas tomber dans le piège de penser qu’un ORM garantit une protection complète contre les injections. Il est indispensable de continuer à valider les données utilisateurs.

Se défendre contre les failles CSRF

Une vulnérabilité CSRF (ou en français falsification de requête intersite) se produit lorsqu’un attaquant arrive à obliger des utilisateurs finaux à exécuter des actions non désirées et sans qu’ils ne s’en rendent compte. Les attaques CSRF visent les demandes de création, modification et suppression de données et sont menées sur les utilisateurs authentifiés.

Pour s’en protéger, le principe est de sécuriser les requêtes et de s’assurer qu’elles proviennent bien de l’application web.

Les requêtes GET doivent seulement aller chercher des ressources, mais jamais modifier un élément sur le serveur.

Vous pouvez prendre plusieurs mesures, comme :

  • Utiliser des token d’authentification (exemple des jetons JWT)
  • Utiliser un token anti-CSRF. Il existe par exemple la libraire csurf.
  • Si l’application utilise des cookies, mettre l’instruction SameSite sur les cookies
  • Utiliser des headers de sécurité HTTP, comme
    • strict-transport-security  – pour passer uniquement par des connexions https
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  • x-frame-options – cet en-tête permet d’autoriser l’inclusion de pages dans des iframes ou non. Des attaquants peuvent en effet créer des iframes avec votre site pour duper les utilisateurs dans des attaques de clickjacking.  Il est généralement recommandé de définir l’en-tête sur Sameorigin (les iframes sont autorisées seulement si l’iframe est sur un site avec le même domain)
  • x-content-type-options – pour que le navigateur tienne compte de la directive content-type et qu’un changement de types MIME ne soit pas possible. La directive recommandée est « X-Content-Type-Options: nosniff »
  • referrer-policy – ce qui permet de définir quels niveaux d’informations le navigateur envoie dans l’en-tête. Vous pouvez consulter les différentes directives possibles sur le site du W3C.
  • permissions-policy – qui définit quelles fonctionnalités des navigateurs et quelles APIs peuvent être utilisées par le navigateur. Vous pouvez jongler entre ‘self’, src ou () pour interdire totalement la fonctionnalité.

Vous pouvez vérifier les headers avec par exemple le site https://securityheaders.com/.

Lié aux CSRF, il est nécessaire de sécuriser les CORS. En effet, s’ils sont mal configurés, cela créera une faille CSRF.

Les CORS (Cross-Origin Resource Sharing) consistent à ajouter des en-têtes http afin d’accéder à des ressources d’un serveur situé sur une autre origine que le site courant.

Dans certains cas, il y a un vrai besoin d’autoriser ce fonctionnement. Il est alors recommandé de définir les domaines depuis lesquels vous vous attendez à avoir des requêtes (très souvent, c’est uniquement votre domaine). Ne laissez pas :

res.setHeader('Access-Control-Allow-Origin', '*')

Prévenir les fuites de données

Vous avez besoin de surveiller plusieurs endroits où de la divulgation d’informations se produit régulièrement. Cela peut être lorsqu’il y a des messages d’erreurs (lors de l’authentification, sur l’infrastructure…) ou dans les fichiers de configuration.

Pour le premier cas, la protection est de continuer à logger les erreurs, mais simplement de ne pas les montrer aux utilisateurs.

Dans le second, il convient de ne pas écrire de secrets dans les fichiers de configuration ni dans le code source. Il est intéressant de vérifier de temps en temps avec des requêtes ou de la revue de code que des secrets ne sont pas accidentellement exposés.

Parer les attaques brute force

Les attaques utilisant le brute force sur les pages d’authentification sont une pratique courante pour essayer d’obtenir un mot de passe ou une clé. Cela consiste à tester toutes les possibilités une à une.

Pour s’en protéger, il s’agit de limiter le nombre de requêtes par IP par minutes par la mise en place d’un rate limiting. Des packages existent pour node, comme rate-limiter, express-brute, … Selon vos besoins, vous pouvez combiner plusieurs modules.

Il est aussi recommandé d’installer un captcha, qui permet de s’assurer que la demande émane d’un utilisateur légitime. Vous pouvez employer Recaptcha de Google ou bien un des modules node.js.

Éviter les erreurs de contrôle d’accès

Node ne fournit pas de solution native pour contrôler les accès. Mais heureusement, de nombreux modules existent et permettent de créer des rôles et assigner des utilisateurs à ces rôles, de gérer des héritages hiérarchiques, comme acl, passport

Le contrôle des droits est idéalement à mettre en place le plus haut possible dans l’organisation.  Dans un environnement Node.js, un middleware est particulièrement adapté pour cela.

Contrôler les paquets installés

Node fonctionne grâce à un système de modules extrêmement étendu. Des milliers de paquets sont disponibles. Cependant, cet avantage peut être source de failles. Certains paquets ne sont plus ou mal maintenus. D’autres intègrent des sous-paquets, ce qui construit un système de sous-dépendance où il est difficile de contrôler et d’examiner tous les paquets.

Pour s’en protéger, il faut combiner :

  • Des outils qui vérifient les modules, comme les gestionnaires de paquets YARN audit ou NPM audit. Ils auditent les packages et permettent de trouver certaines vulnérabilités.
  • Et avoir une bonne politique de maintenance des paquets.

Se protéger des failles race conditions

Node est asynchrone. Cela permet une optimisation de son fonctionnement, mais des problèmes peuvent émerger lorsque des instructions s’effectuent avant d’autres, créant des failles race conditions.

Cette faille est assez dure à détecter. Elle demande d’enquêter après un scan qui aurait eu un comportement étrange par exemple.

Elle apparait lorsqu’un système utilise une ressource partagée, et qu’un intervalle de temps existe entre le moment où une requête de contrôle est faite sur la ressource et le moment où la requête est exécutée.

Pour s’en protéger, il faut être très prudent lors d’un appel asynchrone et s’assurer que, lors d’une opération sensible, toutes les vérifications de sécurité sont bien terminées avant que l’opération elle-même se produise, notamment via les callbacks ou les promises.

Par exemple, un virement fictif où l’on vérifie d’abord si un compte a suffisamment d’argent avant de faire le virement :

verifyAccountBalance() ; // Throw an error if account balance is not enough
transferMoney() ;

Si la fonction verifyAccountBalance est asynchrone, il existe un risque que transferMoney soit appelée avant que celle-ci puisse avoir fini de vérifier si l’opération est possible ou pas.

Il convient donc plutôt d’utiliser, par exemple, un callback pour s’assurer du bon ordre d’exécution :

verifyAccountBalance(function(error) {
	if (error) {
		// Handle error (probably not enough money !)
		return;
}
	transferMoney() ;
});

CONCLUSION

Nous venons de le voir, sécuriser une application Node requiert de vérifier de nombreux éléments : la logique front, le côté serveur, le stockage des données, le transport des données et plus.

Plusieurs des vulnérabilités partagent les mêmes causes et en étudiant les failles les plus fréquentes, vous réussirez à les empêcher et à protéger votre application.