Worker Threads: sandbox seguro para executar código de usuário no Node.js
Por John Pires · 14 de março de 2026

Worker Threads: sandbox seguro para executar código de usuário no Node.js
1) Problema real que resolve
Executar código fornecido por usuários dentro de uma aplicação Node.js é uma tarefa comum para plataformas de execução remota, editores online, ferramentas de avaliação automática e microsserviços que expõem funções personalizadas. O desafio é equilibrar segurança, confiabilidade e performance. Código arbitrário pode travar o loop de eventos, esgotar memória, consumir CPU indefinidamente ou acessar APIs do sistema que não deveria.
Uma abordagem insegura que aparece com frequência em código revisado é usar eval() ou vm.runInThisContext() diretamente no processo principal. Esses métodos executam dentro do mesmo contexto do servidor, abrindo brechas para vazamento de dados e comprometimento do processo.
Worker Threads surgem como alternativa mais sólida: cada worker tem seu próprio isolate V8, o que ajuda a conter crashes e separa o heap. Mas usar worker threads não é mágica — é preciso combinar com patterns e flags adequadas para criar uma sandbox prática e segura.
Por que eval no main thread é problemático
Eval executa no mesmo contexto do processo e pode acessar variáveis, módulos e funcionalidades sensíveis. Mesmo quando você tenta filtrar strings, sempre há formas de contornar. Além disso, um loop infinito bloqueia o event loop, degradando toda a aplicação.
O que queremos garantir
- Isolamento do V8 (heap e contexto)
- Limite de tempo de execução para evitar loops infinitos
- Limite de memória por tarefa
- Comunicação controlada com o código sandboxed
- Reaproveitamento eficiente de recursos para performance
Use worker threads + vm em conjunto: worker isola V8, vm limita API global dentro desse worker. Combine com flags de V8 e timeout para segurança.
2) Abordagem ingênua vs abordagem otimizada
Code-review: vi muitas bases com a mesma falha. Abaixo mostro o jeito errado (ingênuo) e depois a arquitetura otimizada. A crítica é direta: design que executa código de usuário deve assumir que o código é malicioso.
Abordagem ingênua — executar no processo principal
Trecho típico que encontrei em PRs:
// main-eval.js (errado) - executa código do usuário no processo principal
const userCode = "while(true){}"; // potencial código malicioso
try {
const result = eval(userCode);
console.log('resultado', result);
} catch (err) {
console.error('erro na execucao', err);
}
O que acontece depois: loop infinito trava toda a aplicação, ou o usuário chama process.exit(), ou require('fs') para ler arquivos sensíveis. Mesmo tentando filtrar tokens, é difícil cobrir todos os vetores.
Abordagem ligeiramente melhor — vm em outro worker
Melhor prática: mover a execução para um Worker e dentro dele usar vm.Script para limitar o global. Ainda assim, existem detalhes que muitos esquecem: não limitar memória do worker, não impor timeout robusto, ou permitir transferências perigosas entre threads.
Resumo das melhorias necessárias:
- Passar execArgv para limitar heap do worker (--max-old-space-size)
- Executar código dentro de um contexto criado pelo vm, removendo require/process/globals
- Impor timeout e monitorar uso de recursos
- Usar pool de workers para reduzir overhead de spawn
3) Implementação com código real
Aqui eu mostro uma implementação prática: um worker dedicado (sandbox-worker.js) que executa código de usuário usando vm.Script e exposição mínima via message passing. Depois apresento um WorkerPool que gere limites e reuso. Código focado em Node.js 16+.
Arquivo: sandbox-worker.js (o worker que isola execução)
Contexto: este arquivo é o código que roda dentro do worker thread. Ele recebe uma string com o código do usuário via parentPort, executa em um contexto restrito usando vm.Script, aplica timeout e devolve resultado ou erro.
// sandbox-worker.js
const { parentPort, workerData } = require('worker_threads');
const vm = require('vm');
// dados injetados (opcional): { timeoutMs }
const timeoutMs = (workerData && workerData.timeoutMs) || 1000;
function runUserCode(code) {
// Cria sandbox limpo: apenas objetos básicos. Não expõe require, process, etc.
const sandbox = {
console: {
log: (...args) => parentPort.postMessage({ type: 'console', args })
},
Math,
Date,
URL,
// não colocar 'process' ou 'require'
};
const context = vm.createContext(sandbox, { name: 'user-sandbox' });
const script = new vm.Script(code, { filename: 'user-code.js', displayErrors: true });
// runInContext com timeout (apenas interrompe a execução JS, não a thread)
return script.runInContext(context, { timeout: timeoutMs });
}
parentPort.on('message', async (msg) => {
if (msg && msg.type === 'run') {
try {
const result = runUserCode(msg.code);
parentPort.postMessage({ type: 'result', result });
} catch (err) {
parentPort.postMessage({ type: 'error', error: { message: err.message, stack: err.stack } });
}
}
});
O que acontece: worker recebe código, cria um contexto minimalista e executa com timeout do vm.Script. Ainda assim, se o código fizer operações síncronas pesadas (ex: laços muito grandes), timeout do vm interrompe. Mas isso não impede consumo de memória do worker; por isso tomaremos outras medidas no main.
Arquivo: main.js — spawn do worker de forma segura
Contexto: este é o processo principal. Cria o worker apontando para sandbox-worker.js e passa execArgv para limitar memória do worker. Também aplica timeout externo e lide com mensagens do console no sandbox.
// main.js
const { Worker } = require('worker_threads');
const path = require('path');
function runInSandbox(code, { timeoutMs = 1000, maxMemoryMb = 64 } = {}) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(__dirname, 'sandbox-worker.js'), {
workerData: { timeoutMs },
execArgv: [`--max-old-space-size=${maxMemoryMb}`]
});
const killTimeout = setTimeout(() => {
// termina o worker se ultrapassar tempo total
worker.terminate().then(() => reject(new Error('timeout: worker terminated')));
}, timeoutMs + 200); // leve folga além do timeout do vm
worker.on('message', (m) => {
if (m.type === 'console') {
console.log('[sandbox]', ...m.args);
} else if (m.type === 'result') {
clearTimeout(killTimeout);
worker.terminate().then(() => resolve(m.result));
} else if (m.type === 'error') {
clearTimeout(killTimeout);
worker.terminate().then(() => reject(new Error(m.error.message)));
}
});
worker.on('error', (err) => {
clearTimeout(killTimeout);
reject(err);
});
worker.on('exit', (code) => {
if (code !== 0) console.warn('worker exited with code', code);
});
// envia o trabalho
worker.postMessage({ type: 'run', code });
});
}
// exemplo de uso
(async () => {
try {
const r = await runInSandbox('const a = 2+2; a;', { timeoutMs: 500, maxMemoryMb: 32 });
console.log('resultado sandbox:', r);
} catch (err) {
console.error('erro sandbox:', err);
}
})();
O que acontece: o main aplica dois níveis de proteção: limite de heap por execArgv do worker e timeout externo. O vm.Script também aplica timeout de execução. Juntos eles formam uma defesa em profundidade.
WorkerPool: otimização para produção
Contexto: criar e destruir workers para cada requisição é caro. Em cenários com alto throughput, use um pool. O pool precisa ser capaz de reciclar workers e reiniciar aqueles que excederam memória/erros.
// worker-pool.js
const { Worker } = require('worker_threads');
const path = require('path');
class WorkerPool {
constructor(size, workerPath, opts = {}) {
this.size = size;
this.workerPath = workerPath;
this.opts = opts;
this.idle = [];
this.busy = new Set();
for (let i = 0; i < size; i++) this.idle.push(this._createWorker());
}
_createWorker() {
const w = new Worker(this.workerPath, this.opts);
w.available = true;
w.on('exit', (code) => {
// substitui worker que morreu
this._replaceWorker(w);
});
return w;
}
_replaceWorker(old) {
this.idle = this.idle.filter(w => w !== old);
this.busy.delete(old);
const nw = this._createWorker();
this.idle.push(nw);
}
async run(code, runOpts = {}) {
const worker = this.idle.pop();
if (!worker) throw new Error('no-workers-available');
this.busy.add(worker);
worker.available = false;
return new Promise((resolve, reject) => {
const totalTimeout = setTimeout(() => {
worker.terminate().then(() => reject(new Error('timeout')));
}, (runOpts.totalTimeoutMs || 2000));
const onMessage = (m) => {
if (m.type === 'result') {
cleanup();
resolve(m.result);
} else if (m.type === 'error') {
cleanup();
reject(new Error(m.error.message));
}
};
const onError = (err) => {
cleanup();
reject(err);
};
const cleanup = () => {
clearTimeout(totalTimeout);
worker.off('message', onMessage);
worker.off('error', onError);
if (worker.threadId && worker.available === false) {
worker.available = true;
this.busy.delete(worker);
this.idle.push(worker);
}
};
worker.on('message', onMessage);
worker.on('error', onError);
worker.postMessage({ type: 'run', code });
});
}
}
module.exports = WorkerPool;
O que acontece: pool gerencia workers, reaproveita instâncias e substitui workers que morrem. É importante testar cenários de stress para garantir que a pool não mantenha workers inválidos.
4) Testes e validação
Testes devem comprovar dois pontos: que a sandbox devolve resultados corretos e que ela resiste a casos maliciosos (loop infinito, consumo de memória, tentativas de acesso a APIs proibidas).
Testes de funcionalidade
Um teste simples usando node assertion:
// test-sandbox.js
const assert = require('assert');
const { runInSandbox } = require('./main'); // supondo export
(async () => {
const r = await runInSandbox('1+2', { timeoutMs: 200 });
assert.strictEqual(r, 3);
console.log('passou: soma');
})();
Após executar, o teste deve imprimir que passou. Isso garante que a comunicação e execução básica funcionam.
Testes de segurança e limites
Casos a validar:
- Loop infinito: código como while(true){} deve ser terminado por timeout externo/VM
- Uso alto de memória: worker deve ser terminado quando excede --max-old-space-size
- Tentativa de require/process: executar require('fs') deve falhar porque não existe no contexto do vm
Exemplo de teste para loop infinito:
// test-timeout.js
const { runInSandbox } = require('./main');
(async () => {
try {
await runInSandbox('while(true){}', { timeoutMs: 100 });
console.error('ERRO: deveria ter timed out');
} catch (err) {
console.log('passou: timeout detectado ->', err.message);
}
})();
O resultado esperado é que o sandbox lance erro por timeout e o worker seja terminado. Em pipelines CI, automatize esses testes com tempo máximo definido.
5) Considerações de produção
Ao levar isso para production, considere mitigação adicional fora do Node:
- Containerização por tenant ou uso de processos isolados via Kubernetes com quotas
- Policies de segurança (AppArmor, SELinux) para bloquear syscalls sensíveis
- Monitoração: expose métricas por worker (memória, CPU, tempo de vida) e alerte quando um worker ultrapassa limites
Erros comuns e armadilhas
1) Achar que vm.createContext impede tudo: o vm evita acesso a variáveis globais não definidas, mas se você inserir objetos sensíveis no sandbox, o código terá acesso. Nunca injete referências a objetos do Node que possam manipular o sistema.
2) Confiar apenas no timeout do vm: vm.Script tem timeout, mas é aconselhável um timeout externo que termine o worker para cobrir falhas no sibling.
3) Passar funções ou objetos não clonáveis pelo parentPort: a comunicação é feita por structured clone; passar funções resulta em erro ou comportamento inesperado. Sempre passar dados serializáveis (JSON-safe) e usar ids para recursos maiores.
Boas práticas
- Use execArgv para limitar heap por worker: --max-old-space-size
- Monitore e reinicie workers que crescem demais
- Implemente rede limitada: evite que o worker tenha acesso livre à rede. Se necessário, controle via proxies ou políticas de rede
- Combine camadas de proteção: vm + worker + flags + container policies
Uma sandbox robusta é uma pilha de proteções: nenhuma técnica isolada é suficiente contra todos os vetores de ataque.
Conclusão prática
Resumo direto: não execute código de usuário no processo principal. Use worker threads para isolar o V8, execute o código dentro de vm.Script para controlar globals e timeout, aplique --max-old-space-size via execArgv para limitar memória e utilize uma pool para performance. Complementar com políticas de sistema (AppArmor, contêiner) eleva muito a segurança.
Checklist prático antes de ir para produção:
- Remover qualquer exposição de require/process/Buffer no contexto do vm
- Impor timeout do vm e timeout externo que termine o worker
- Lançar workers com execArgv limitando memória
- Adicionar monitoração e logs das ações do sandbox (ex.: console.log encaminhado com precaução)
- Testar cenários maliciosos de forma automatizada
Essas práticas reduzem drasticamente a superfície de ataque e mantêm a aplicação responsiva. Implementações fáceis de revisar e com política de reinício automatizada reduzem risco operacional. Se a segurança exigida for muito alta, combine com isolamento a nível de processo/contêiner e políticas de kernel.
Este artigo mostrou o caminho errado, o caminho certo e uma base de código prática para começar. Aplique as verificações e adapte os valores de timeout/memory conforme o perfil de uso. Em code reviews, busque padrões: se você ver eval no main process, sinalize como crítica.
Assuntos: Node.js, worker-threads, sandbox, vm, Segurança
Leia também
Abortar pipelines de Streams em Node.js: AbortController, timeouts e limpeza de Workers
Guia prático para abortar pipelines de streams em Node.js: uso de AbortController, patterns de timeout, e limpeza segura de Worker Threads para evitar…
Autenticação com Passkeys (WebAuthn) em SPA e API: arquitetura e fallback JWT
Arquitetura prática para usar Passkeys/WebAuthn em SPAs com API: pattern attempt-first WebAuthn com fallback JWT seguro. Padrões, exemplos de código, testes…
Diagnóstico de latência em APIs Node.js com async_hooks, event-loop e GC
Diagnostique latência em APIs Node.js combinando async_hooks/AsyncLocalStorage, monitorEventLoopDelay e análise de GC. Passo a passo prático: medir…
Gerar thumbnails e checksums de uploads usando Streams e Worker Threads no Node.js
Como gerar thumbnails e checksums de uploads sem sobrecarregar o Node: padrões com Streams (PassThrough/pipeline) e offload para Worker Threads. Boas…
Paralelizando resolvers CPU-bound no Apollo/GraphQL com Worker Threads e pool
Evite bloqueio do event loop em resolvers CPU-bound no Apollo/GraphQL usando worker_threads com pool. Guia prático com implementação, integração com Apollo…
Processar uploads CSV grandes com Streams, Worker Threads e progresso via SSE em Node.js
Processar CSV grandes sem travar o Node: mostro o jeito errado (ler tudo) e a solução correta com streams, worker threads e SSE para progresso em tempo real…