Blog

SQL injection em 2026: por que ainda acontece.

SQL injection é a falha mais bem documentada da história do desenvolvimento web. Todo curso ensina. Todo ORM protege. Toda linguagem tem consulta parametrizada.

E ela continua aparecendo em relatório de teste de invasão, inclusive em sistema moderno com ORM.

A razão é que ninguém mais escreve "SELECT * FROM users WHERE id = " + id. A injeção mora nos lugares onde a proteção não chega, e quase ninguém sabe quais são esses lugares.

Os quatro lugares onde ela ainda vive

1. Ordenação e nome de coluna dinâmicos.

Consulta parametrizada protege valor. Não protege identificador.

// isso não pode ser parametrizado. o placeholder não funciona aqui.
const rows = await db.query(
  `select * from orders order by ${req.query.sort} ${req.query.dir}`
);

?sort=id&dir=asc funciona. ?sort=(select ...) também.

A única defesa é lista de permissão:

const SORTABLE = { id: "o.id", total: "o.total_minor", date: "o.created_at" } as const;
const DIRECTIONS = { asc: "ASC", desc: "DESC" } as const;

const col = SORTABLE[req.query.sort] ?? SORTABLE.date;
const dir = DIRECTIONS[req.query.dir] ?? "DESC";

Não sanitize, não escape. Mapeie pra um valor que você escreveu.

2. Fragmento cru dentro do query builder.

Todo ORM tem uma porta de saída pra SQL cru, e ela é usada exatamente quando a consulta ficou complicada, que é quando a atenção está no problema difícil e não na segurança.

// TypeORM: o where com template literal
qb.where(`o.status = '${status}'`);          // vulnerável

qb.where("o.status = :status", { status });  // parametrizado

O mesmo vale pro having, pro orderBy e pro addSelect. São métodos que aceitam string e não avisam nada.

3. Filtro dinâmico montado a partir de objeto.

O padrão de API que aceita filtro genérico:

// GET /orders?filter[status]=paid
for (const [field, value] of Object.entries(req.query.filter)) {
  qb.andWhere(`o.${field} = :${field}`, { [field]: value });
}

O valor está parametrizado. O nome do campo não. filter[id=1 or 1=1--] entra direto.

Lista de permissão de novo, e sempre.

4. Migração e script operacional.

O lugar mais esquecido. Script que roda uma vez, escrito às pressas, com psql -c "update ... where email = '$EMAIL'". Um e-mail com aspas simples quebra, e um e-mail malicioso faz outra coisa.

Não parece exposto porque não é endpoint. Passa a ser quando o valor vem de um CSV que alguém mandou.

O que a injeção consegue fazer

Vale entender pra calibrar a gravidade, porque muita gente acha que o pior caso é ler dado.

Ler qualquer tabela, inclusive as que aquele usuário não deveria alcançar. Escrever e apagar. Ler arquivo do servidor, dependendo da configuração do banco. Em Postgres com usuário superusuário, executar comando no sistema operacional.

E a variante que mais aparece em teste: injeção cega baseada em tempo, onde o atacante não vê a resposta e mede quanto tempo ela demora. and (select pg_sleep(5)) responde cinco segundos mais devagar quando a condição é verdadeira, e com isso se extrai a base inteira, um bit por vez, sem nenhuma mensagem de erro pra denunciar.

Por isso “a gente não mostra erro de banco pro usuário” não é defesa.

As defesas, em ordem

Consulta parametrizada em tudo que é valor. Não negociável.

Lista de permissão pra tudo que é identificador. Coluna, tabela, direção de ordenação, nome de índice.

Usuário de banco com o mínimo. A aplicação não precisa de SUPERUSER, não precisa de CREATE, não precisa ler pg_shadow. Isso não impede a injeção e reduz muito o que ela alcança.

create role app_user login password '...';
grant select, insert, update, delete on all tables in schema public to app_user;
revoke create on schema public from app_user;

Limite de linhas e timeout. Uma consulta que devolve a tabela inteira ou demora um minuto é anormal. statement_timeout e paginação obrigatória transformam extração completa em coisa muito mais difícil.

Teste automatizado com carga hostil. Uma lista de strings de ataque passando por todos os parâmetros de todos os endpoints. Não substitui teste de invasão e pega o óbvio, que é a maioria.

const PAYLOADS = ["' or 1=1--", "'; select pg_sleep(3)--", "1) or (1=1"];

describe.each(PAYLOADS)("payload %s", (payload) => {
  it("não altera o comportamento da listagem", async () => {
    const res = await request(app).get(`/orders?sort=${encodeURIComponent(payload)}`);
    expect([200, 400]).toContain(res.status);
    expect(res.body.data?.length ?? 0).toBeLessThanOrEqual(20);
  });
});

O detalhe que muda tudo no code review

Uma regra simples, que a máquina pode verificar: nenhuma string com interpolação pode chegar num método que executa SQL.

"no-restricted-syntax": ["error", {
  selector: "CallExpression[callee.property.name=/^(query|where|andWhere|orderBy|having)$/] > TemplateLiteral[expressions.length>0]",
  message: "Sem interpolação em SQL. Use parâmetro ou lista de permissão.",
}],

Regra de lint pega isso em toda linha nova, inclusive nas que forem escritas às três da manhã durante um incidente. É a única defesa que não depende de alguém estar atento.

Sobre a família de falhas em que essa se encaixa, escrevi em como construir aplicações seguras.

← Voltar pro blog