Destino Dev

Blog

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:

  1. Passar execArgv para limitar heap do worker (--max-old-space-size)
  2. Executar código dentro de um contexto criado pelo vm, removendo require/process/globals
  3. Impor timeout e monitorar uso de recursos
  4. 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:

  1. Remover qualquer exposição de require/process/Buffer no contexto do vm
  2. Impor timeout do vm e timeout externo que termine o worker
  3. Lançar workers com execArgv limitando memória
  4. Adicionar monitoração e logs das ações do sandbox (ex.: console.log encaminhado com precaução)
  5. 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