Node.js e MySQL: a consulta que não cola o que veio da tela
Use o mysql2 com pool e consultas parametrizadas, trate nome de coluna e ordenação por lista fechada e libere conexões mesmo quando a transação falha.
A busca de clientes filtra pelo nome e funciona no teste. Alguém pesquisa D'Ávila e a API devolve erro de sintaxe SQL. Outra pessoa pesquisa ' OR '1'='1 e recebe a lista inteira. A consulta foi montada colando o texto da requisição dentro da string SQL, e o banco executou o que a pessoa escreveu. A resposta curta: com o pacote mysql2, escreva o SQL fixo com marcadores ? e passe os valores num array separado, com pool.execute(sql, valores). Nome de coluna e direção de ordenação não podem ser parâmetro: eles saem de uma lista fechada no seu código.
Por que colar a string abre o banco
Quando você escreve "SELECT * FROM clientes WHERE nome = '" + busca + "'", o banco recebe um único texto e não tem como saber onde termina o seu comando e começa o dado do usuário. Uma aspa no valor fecha a string que você abriu, e o resto vira SQL. Isso é injeção de SQL, e ela continua no topo das falhas exploradas porque é fácil de introduzir e fácil de achar.
Com parâmetro, o comando e os valores viajam separados. Usando execute, o mysql2 prepara o comando no servidor e envia os valores à parte. O valor nunca é interpretado como SQL, por mais aspas que tenha. Usando query com ?, a biblioteca escapa os valores no cliente antes de montar o texto. É seguro para valores, mas execute deixa a separação mais explícita e reaproveita a preparação em consultas repetidas.
Parâmetro só substitui valores. Você não pode usar ? no lugar de nome de tabela, nome de coluna, ASC ou DESC. É justamente aí que muita gente volta a concatenar, e a injeção volta pela ordenação.
Como montar a conexão
- Instale o driver com
npm install mysql2e use a interface de promessas,mysql2/promise. - Crie um único pool no início da aplicação, com limite de conexões, e reutilize em todas as rotas.
- Leia host, usuário e senha de variáveis de ambiente.
- Defina um tempo limite de conexão, para a requisição não ficar pendurada quando o banco some.
- Deixe
multipleStatementsdesligado, que é o padrão. Com ele ligado, uma injeção pode encadear um segundo comando.
// db.js
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
connectTimeout: 5000,
charset: 'utf8mb4',
});
module.exports = pool;
Abrir uma conexão nova a cada requisição, sem fechar, funciona com três usuários e derruba o banco com trezentos, quando o MySQL atinge o limite de conexões. O pool mantém algumas abertas e entrega para quem pede. Se todas estão ocupadas, a requisição espera na fila em vez de criar mais uma.
Como escrever a consulta com parâmetro
O exemplo abaixo é uma rota Express que busca clientes por nome, com ordenação escolhida pelo usuário e paginação simples:
// app.js
const express = require('express');
const pool = require('./db');
const app = express();
const COLUNAS_ORDEM = new Map([
['nome', 'nome'],
['recentes', 'criado_em'],
]);
app.get('/clientes', async (req, res) => {
const busca = typeof req.query.busca === 'string' ? req.query.busca : '';
const coluna = COLUNAS_ORDEM.get(req.query.ordem) ?? 'nome';
const direcao = req.query.dir === 'desc' ? 'DESC' : 'ASC';
const pagina = Math.min(500, Math.max(1, Number.parseInt(req.query.pagina, 10) || 1));
const limite = 20;
const deslocamento = (pagina - 1) * limite;
const sql =
'SELECT id, nome, email FROM clientes WHERE nome LIKE ? ' +
'ORDER BY ' + coluna + ' ' + direcao + ', id ' +
'LIMIT ' + limite + ' OFFSET ' + deslocamento;
try {
const [linhas] = await pool.execute(sql, ['%' + busca + '%']);
res.json(linhas);
} catch (erro) {
console.error('Falha ao listar clientes', erro);
res.status(500).json({ erro: 'Não foi possível listar os clientes.' });
}
});
app.listen(3000);
O que importa em cada parte:
- O termo da busca vai como parâmetro. Os
%doLIKEsão concatenados ao valor, não ao SQL. Se a pessoa digitar%ou_, eles funcionam como curinga, o que numa busca simples costuma ser aceitável. - A coluna de ordenação sai de um
Map. Se o valor pedido não está lá, vale o padrão. Um objeto comum como lista branca tem uma pegadinha:ORDENS['constructor']devolve uma função herdada deObject, que vira texto dentro do SQL. OMapsó responde o que você colocou. - A direção é uma de duas strings fixas, escolhida por comparação.
- Limite e deslocamento são números calculados pelo código, nunca texto da requisição. Em algumas combinações de versão do
mysql2e do MySQL, passarLIMIT ?com número noexecutegera erro de argumentos. Concatenar um inteiro que você mesmo validou evita o problema sem abrir brecha. - O erro vai completo para o log e genérico para o cliente. Mensagem de erro do MySQL na resposta entrega nome de tabela e estrutura para quem está sondando.
Para listas com IN, o execute não expande um array num único ?. Gere um marcador por item com ids.map(() => '?').join(', ') e passe o array como valores, depois de validar que cada item é um inteiro e de limitar o tamanho da lista.
Paginação por OFFSET resolve listas pequenas e paradas. Para listas que mudam enquanto a pessoa navega, veja paginação por cursor.
Transação sem vazar conexão
Quando duas escritas precisam acontecer juntas, pegue uma conexão do pool, abra a transação e devolva a conexão no finally, aconteça o que acontecer:
async function criarPedido(clienteId, itens) {
const conexao = await pool.getConnection();
try {
await conexao.beginTransaction();
const [resultado] = await conexao.execute(
'INSERT INTO pedidos (cliente_id, criado_em) VALUES (?, NOW())',
[clienteId]
);
for (const item of itens) {
await conexao.execute(
'INSERT INTO itens_pedido (pedido_id, produto_id, quantidade) VALUES (?, ?, ?)',
[resultado.insertId, item.produtoId, item.quantidade]
);
}
await conexao.commit();
return resultado.insertId;
} catch (erro) {
await conexao.rollback();
throw erro;
} finally {
conexao.release();
}
}
Sem o release no finally, cada erro deixa uma conexão presa. Depois de dez erros, com connectionLimit: 10, a aplicação para de responder sem nenhuma mensagem clara. Esse é um dos travamentos mais confusos de diagnosticar em produção.
ORM e query builder também concatenam
Usar Knex, Sequelize ou Prisma não elimina o risco. Todos oferecem uma saída para SQL cru, e é por ela que a injeção volta. No Prisma, $queryRaw usado como template marcado parametriza os valores, enquanto $queryRawUnsafe recebe uma string pronta, e o nome já avisa. No Sequelize, consultas cruas devem usar replacements ou bind, não concatenação. A regra é a mesma do driver: valor por parâmetro, identificador por lista fechada.
Por fim, o usuário do banco que a aplicação usa deve ter só as permissões de que precisa no schema dela. Se uma injeção escapar, o estrago fica limitado ao que esse usuário pode fazer.
Erros comuns
- Montar SQL com concatenação ou template string com valores da requisição. Uma aspa vira comando, e a busca devolve ou apaga o que não devia.
- Aceitar nome de coluna da URL "porque não dá para parametrizar". A ordenação vira o ponto de injeção.
- Lista branca em objeto comum. Chaves herdadas como
constructorpassam no filtro. - Criar conexão por requisição sem fechar. O MySQL esgota o limite de conexões em horário de pico.
- Esquecer o
releaseem caminho de erro. O pool seca aos poucos, e a aplicação trava sem log útil. - Devolver a mensagem do MySQL para o cliente. Estrutura do banco vaza para quem está testando injeção.
Checklist
- Nenhuma consulta concatena valor vindo de
req.query,req.bodyoureq.params. - Colunas e direções de ordenação saem de lista fechada.
- Existe um único pool com
connectionLimiteconnectTimeoutdefinidos. multipleStatementscontinua desligado.- Toda conexão obtida com
getConnectioné liberada nofinally. - Credenciais do banco vêm de variáveis de ambiente, fora do Git.
- Erros vão completos para o log e genéricos para a resposta.
Abra agora a rota de busca da sua API e pesquise por ' OR '1'='1. A lista veio vazia, como deveria, ou veio todo mundo? E, no código dessa rota, de onde sai o nome da coluna do ORDER BY?
Referência: https://www.luiztools.com.br/post/como-usar-nodejs-mysql/