Programação Por Equipe DevBras

Capa do artigo: Node.js e MySQL: a consulta que não cola o que veio da tela

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

  1. Instale o driver com npm install mysql2 e use a interface de promessas, mysql2/promise.
  2. Crie um único pool no início da aplicação, com limite de conexões, e reutilize em todas as rotas.
  3. Leia host, usuário e senha de variáveis de ambiente.
  4. Defina um tempo limite de conexão, para a requisição não ficar pendurada quando o banco some.
  5. Deixe multipleStatements desligado, 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 % do LIKE sã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 de Object, que vira texto dentro do SQL. O Map só 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 mysql2 e do MySQL, passar LIMIT ? com número no execute gera 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 constructor passam 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 release em 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.body ou req.params.
  • Colunas e direções de ordenação saem de lista fechada.
  • Existe um único pool com connectionLimit e connectTimeout definidos.
  • multipleStatements continua desligado.
  • Toda conexão obtida com getConnection é liberada no finally.
  • 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?

Avalie este artigo

Referência: https://www.luiztools.com.br/post/como-usar-nodejs-mysql/