Evitar SQL Injection no Node.js é obrigatório em qualquer aplicação que constrói consultas a partir de dados externos. O ataque acontece quando valores fornecidos pelo usuário são interpretados como parte do comando SQL, permitindo alterar filtros, acessar registros indevidos, modificar dados ou executar operações administrativas.
A principal defesa é separar código e dados por meio de parâmetros. Validação, allowlists, permissões mínimas e testes complementam a proteção. Escapar strings manualmente ou confiar apenas em um ORM não é suficiente quando nomes de coluna, ordenação e fragmentos SQL continuam sendo concatenados.
Neste guia, você aprenderá queries parametrizadas, prepared statements, identificadores dinâmicos, filtros, LIKE, listas, ORMs, logs, privilégios, testes e defesa em profundidade.
O que é SQL Injection?
SQL Injection ocorre quando entrada não confiável modifica a estrutura da consulta. A referência da OWASP sobre SQL Injection explica o ataque e as defesas. A documentação de queries do node-postgres demonstra parâmetros seguros.
Para conexões, consulte Pool PostgreSQL no Node.js. Para uma camada adicional de isolamento, veja Row-Level Security no Node.js.
Exemplo vulnerável
const sql = `
SELECT id, email
FROM users
WHERE email = '${req.query.email}'
`;
const result = await pool.query(sql);Uma entrada contendo aspas e operadores pode alterar o WHERE.
Query parametrizada
const result = await pool.query(`
SELECT id, email
FROM users
WHERE email = $1
`, [req.query.email]);O driver envia o valor separadamente. O PostgreSQL não o interpreta como sintaxe SQL.
Parâmetros não são interpolação
Não faça:
const value = escapeSomehow(input);
const sql = `SELECT * FROM users WHERE email = '${value}'`;Escapes manuais são frágeis diante de encoding, tipos, configurações e manutenção.
Todos os valores devem ser parâmetros
const result = await pool.query(`
SELECT id, status, total_cents
FROM orders
WHERE tenant_id = $1
AND customer_id = $2
AND created_at >= $3
`, [tenantId, customerId, startDate]);Identificadores não aceitam parâmetros
Placeholders não podem representar nomes de tabela ou coluna:
SELECT * FROM $1Para ordenação dinâmica, use allowlist.
Allowlist de ordenação
const sortOptions = {
newest: 'created_at DESC',
oldest: 'created_at ASC',
total_high: 'total_cents DESC',
total_low: 'total_cents ASC'
};
const orderBy = sortOptions[req.query.sort]
|| sortOptions.newest;
const sql = `
SELECT id, total_cents, created_at
FROM orders
WHERE tenant_id = $1
ORDER BY ${orderBy}
LIMIT $2
`;A string usada vem apenas de valores internos predefinidos.
Nunca concatene nome recebido
ORDER BY ${req.query.column}Mesmo que o valor pareça um nome simples, um atacante pode inserir expressão ou subconsulta.
Direção ASC ou DESC
const direction = req.query.direction === 'asc'
? 'ASC'
: 'DESC';Escolha entre duas constantes, sem reutilizar o texto recebido.
LIKE parametrizado
const term = `%${searchTerm}%`;
const result = await pool.query(`
SELECT id, name
FROM products
WHERE tenant_id = $1
AND name ILIKE $2
`, [tenantId, term]);O parâmetro evita injection, mas caracteres % e _ continuam funcionando como curingas. Escape-os quando o requisito for busca literal.
Escapando curingas do LIKE
function escapeLike(value) {
return value.replace(/[\\%_]/g, '\\$&');
}
const term = `%${escapeLike(searchTerm)}%`;
const result = await pool.query(`
SELECT id, name
FROM products
WHERE name ILIKE $1 ESCAPE '\\'
`, [term]);Listas com ANY
const result = await pool.query(`
SELECT id, name
FROM products
WHERE tenant_id = $1
AND id = ANY($2::uuid[])
`, [tenantId, productIds]);Não construa uma lista separada por vírgula manualmente.
IN dinâmico
Quando a biblioteca não aceita arrays diretamente, gere placeholders com base na quantidade, nunca com base no conteúdo:
const placeholders = ids.map((_, index) =>
`$${index + 2}`
).join(', ');
const sql = `
SELECT id
FROM products
WHERE tenant_id = $1
AND id IN (${placeholders})
`;
const values = [tenantId, ...ids];Limite e offset
const limit = Math.min(
Math.max(Number(req.query.limit) || 20, 1),
100
);
const offset = Math.max(
Number(req.query.offset) || 0,
0
);
await pool.query(`
SELECT id, name
FROM products
WHERE tenant_id = $1
ORDER BY id
LIMIT $2 OFFSET $3
`, [tenantId, limit, offset]);Use inteiros validados e ainda envie como parâmetros.
Filtros opcionais
const conditions = ['tenant_id = $1'];
const values = [tenantId];
if (status) {
values.push(status);
conditions.push(`status = $${values.length}`);
}
if (customerId) {
values.push(customerId);
conditions.push(`customer_id = $${values.length}`);
}
const sql = `
SELECT id, status
FROM orders
WHERE ${conditions.join(' AND ')}
`;Os fragmentos são internos e os valores permanecem parametrizados.
Query builders
Bibliotecas como Kysely e Drizzle geram SQL parametrizado quando APIs normais são usadas. Consulte Kysely com TypeScript e SQL e Drizzle ORM com PostgreSQL.
Raw SQL em ORM
APIs de raw query podem reintroduzir o risco:
db.execute(`SELECT * FROM users WHERE email = '${email}'`);Use o mecanismo de parâmetros da biblioteca.
Prisma
Métodos do cliente Prisma parametrizam valores. Funções unsafe ou concatenação em $queryRawUnsafe devem ser evitadas.
Prepared statements
Prepared statements também separam consulta e valores. O próximo artigo aprofunda uso, cache e limitações no PostgreSQL.
Validação ainda é necessária
Parâmetros impedem injection, mas não impedem dados inválidos. Valide UUID, enum, data, limites, tamanho e formato.
Validação não substitui parâmetros
Uma regex pode ter falha, mudar no futuro ou permitir um caso inesperado. Continue parametrizando mesmo dados validados.
Segunda ordem
Uma entrada maliciosa pode ser armazenada corretamente e depois concatenada em outra query. Dados do banco também devem ser tratados como não confiáveis quando entram em SQL dinâmico.
Stored procedures
Uma função PostgreSQL pode continuar vulnerável se construir SQL com EXECUTE e concatenação. Use format com identificadores seguros e parâmetros USING.
Busca por JSON
SELECT id
FROM events
WHERE payload @> $1::jsonb;Serializar JSON como parâmetro é mais seguro que montar um literal.
Full-text search
SELECT id, title
FROM articles
WHERE search_vector @@ plainto_tsquery($1);Prefira funções que tratam o texto como consulta, sem concatenar operadores arbitrários.
Multi-tenancy
Uma query segura contra injection ainda pode vazar dados se esquecer o tenant. Inclua o filtro e considere RLS.
Veja Multi-Tenancy no Node.js.
Permissões mínimas
A role da aplicação não deve ser superuser nem possuir DDL. Mesmo que uma falha exista, o impacto fica limitado.
Role somente leitura
Serviços de consulta podem usar uma credencial que não possui INSERT, UPDATE ou DELETE.
Separação de migration
O usuário de migration possui privilégios maiores e não deve ser usado pela API.
Statement timeout
Injection também pode tentar consultas caras. Defina timeouts e limites, embora isso não substitua a correção.
Logs
Não registre query com parâmetros sensíveis. Registre nome lógico, duração, SQLSTATE e quantidade de linhas.
Erros para o cliente
Não retorne SQL, tabela, coluna ou stack:
{
"code": "INVALID_REQUEST",
"message": "Não foi possível processar a solicitação"
}Monitoramento
Observe erros de sintaxe inesperados, consultas muito lentas, padrões de aspas em parâmetros e aumento de 400 ou 500. Não bloqueie apenas por palavras-chave, pois gera falsos positivos.
WAF
Um Web Application Firewall pode reduzir tentativas conhecidas, mas não corrige concatenação no código.
Revisão de código
Procure por:
- template strings em SQL;
- concatenação com
+; - APIs raw unsafe;
- ORDER BY dinâmico;
- nomes de tabela por input;
- funções SECURITY DEFINER;
- scripts administrativos;
- queries em migrations.
Static analysis
Regras de lint e ferramentas SAST podem detectar padrões de concatenação. Configure para as bibliotecas usadas.
Testes de integração
Use um PostgreSQL real e entradas com aspas, comentários, Unicode, curingas e listas vazias.
Teste básico
test('trata texto como valor', async () => {
const malicious = "x' OR '1'='1";
const result = await findUserByEmail(malicious);
assert.equal(result, null);
});Teste de ordenação
Envie um valor fora da allowlist e confirme uso do padrão, sem erro SQL.
Teste de autorização
Mesmo com entrada maliciosa, confirme que um tenant não acessa dados de outro.
Teste de logs
Garanta que senhas, tokens e dados pessoais não aparecem quando a query falha.
Erros comuns
- Escapar manualmente: casos especiais permanecem.
- Parametrizar valores e concatenar ORDER BY: injection continua possível.
- Confiar no ORM: raw queries reabrem a vulnerabilidade.
- Validar sem parametrizar: uma mudança quebra a defesa.
- Role com privilégios amplos: impacto aumenta.
- Retornar erro do banco: estrutura interna é exposta.
- Esquecer scripts internos: ferramentas administrativas também são atacáveis.
Boas práticas
- Parametrize todos os valores.
- Use allowlist para identificadores.
- Valide tipos e limites.
- Evite APIs unsafe.
- Use role restrita.
- Inclua tenant e RLS.
- Defina timeouts.
- Não exponha erros SQL.
- Revise queries dinâmicas.
- Teste com entradas hostis.
Conclusão
Evitar SQL Injection no Node.js começa com queries parametrizadas. Valores nunca devem ser concatenados ao SQL, e identificadores dinâmicos precisam vir de allowlists internas.
A defesa fica mais forte com validação, privilégios mínimos, RLS, timeouts e testes. ORMs e query builders ajudam, mas não eliminam o risco de raw SQL. Quando código e dados permanecem separados em todas as camadas, a aplicação reduz drasticamente a possibilidade de uma entrada externa assumir controle da consulta.




