Injeção de SQL
Uma verificação de login é uma pergunta feita ao banco de dados. Existe uma linha em que o nome de usuário e a senha correspondem ao que foi digitado?
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`Uma linha retorna, as credenciais estavam corretas. Nenhuma linha, elas não estavam.
Isso parece uma pergunta. Para o banco de dados, é uma frase, e o visitante teve a chance de escrever parte dela.
Uma query construída colando strings permite que quem a envia decida o que ela significa.
O bug
SQL é uma linguagem, e essa query é um programa escrito do zero a cada requisição. A maior parte vem de você. Duas partes vêm de quem estiver no teclado.
O banco de dados nunca vê essa costura. Quando a query chega, ela é um único pedaço contínuo de texto, e o banco não tem como saber quais caracteres você escreveu e quais foram escritos por um estranho. Ele interpreta tudo e faz o que está escrito.
Isso é injeção de SQL: uma entrada que é interpretada como parte da query em vez de como um valor dentro dela. As consequências decorrem de tudo o que SQL é capaz de expressar, que é bastante coisa:
- Ler linhas que quem fez a requisição jamais deveria ver.
- Alterar ou apagar dados.
- Executar comandos administrativos contra o sistema de banco de dados.
- Em algumas configurações, alcançar o sistema operacional por trás do banco.
O que você está realmente fazendo é entregar ao banco de dados uma frase já pronta, na esperança de que as palavras fornecidas por outra pessoa sejam apenas palavras.
O ataque
Deixe a senha em paz. Digite isto como nome de usuário:
' OR 1=1 --Substituído no template, o banco de dados recebe:
SELECT * FROM users
WHERE username = '' OR 1=1 --' AND password = ''Três caracteres fizeram todo o trabalho:
- A
'inicial fecha a string do nome de usuário antes da hora, então tudo que vem depois é lido como sintaxe de query, e não como um nome. OR 1=1é uma condição sempre verdadeira, então a cláusulaWHEREinteira é satisfeita para todas as linhas.--inicia um comentário SQL, então a verificação de senha que vem depois é apenas texto que o banco ignora.
Todos os usuários retornam. A aplicação considera a primeira linha como prova de um login bem-sucedido e deixa o atacante entrar como outra pessoa.
Aponte isso apenas para o seu próprio banco de dados
Esses payloads alteram e destroem dados. Isso os torna perigosos em qualquer lugar com registros reais, incluindo uma cópia de staging da produção. Use um banco de dados local que você possa apagar e recriar, ou um sistema para o qual você tenha permissão por escrito para testar.
Feche as aspas para passar a escrever query em vez de texto. Adicione uma condição sempre verdadeira. Comente tudo aquilo com que você não quer lidar.
A correção
Pare de construir a frase. Descreva a estrutura dela uma única vez e entregue os valores separadamente:
const query = 'SELECT * FROM users WHERE username = ? AND password = ?'
const [rows] = await db.execute(query, [username, password])As marcas ? são placeholders. A sintaxe exata varia de banco para banco e de biblioteca para biblioteca — $1 e $2 em alguns casos, parâmetros nomeados em outros —, mas o formato é sempre o mesmo: o texto da query é fixado antes de qualquer valor do usuário entrar em cena.
Isso é chamado de prepared statement com variable binding, ou, mais comumente, query parametrizada.
Aqui o formato é definido primeiro. Os valores chegam depois, como valores, e nada neles pode mudar uma decisão que já foi tomada.
Por que a correção funciona
Nada aqui inspeciona a entrada ou remove qualquer coisa dela. ' OR 1=1 -- ainda chega exatamente como foi digitado.
O que mudou foi onde esse valor pousa. O banco de dados já sabe que está procurando um nome de usuário e uma senha, então o payload é comparado como um nome de usuário, caractere por caractere, contra uma coluna de nomes.
Nenhuma conta se chama ' OR 1=1 --. Nenhuma linha retorna, o login falha, e essa é a resposta correta.
Essa é a defesa na camada do banco de dados, e ela se soma às outras que esta seção vem construindo:
| Camada | O que ela decide |
|---|---|
| Validação no navegador | Se o formulário é enviado, apenas para usuários honestos |
| Validação de schema no servidor | Se a requisição tem o formato certo para sequer ser aceita |
| Saída segura | Se um valor armazenado pode virar código em uma página |
| Queries parametrizadas | Se um valor pode se tornar parte de uma query |
Vale a pena guardar essa ideia: as correções mais seguras costumam mudar como um valor é tratado, em vez de tentar avaliar se ele parece perigoso.
Coloque em prática
Um formulário de cadastro insere um nome e um email:
const query = `
INSERT INTO users (name, email)
VALUES ('${name}', '${email}')
`Assuma o papel do atacante. Crie um valor para o campo de email que apague completamente a tabela users.
Três coisas para resolver: como sair de dentro do valor entre aspas, como sair de dentro dos parênteses, e como encerrar uma instrução para que uma segunda possa começar.
Compare suas respostas
O valor do email é:
'); DROP TABLE users; --O que produz:
INSERT INTO users (name, email)
VALUES ('Dave', ''); DROP TABLE users; --')Quatro movimentos, correspondendo às três perguntas mais uma arrumação final:
'fecha a string dentro da qual o email estava.)fecha o parêntese doVALUES, completando oINSERTcomo uma instrução válida.;encerra essa instrução, então o que vem depois é lido como uma nova.--comenta o')que sobra solto no final, o que de outra forma seria um erro de sintaxe e faria tudo ser rejeitado.
O último movimento é o que costuma passar despercebido. Sem ele, a instrução fica malformada, o banco de dados rejeita tudo, e o ataque falha por motivos que não têm nada a ver com suas defesas.
Vale saber: muitos drivers rejeitam múltiplas instruções em uma única chamada por padrão, então esse payload exato nem sempre funciona. Isso é uma configuração te protegendo, não uma correção. A mesma brecha ainda permite ler qualquer dado que o atacante queira por meio de um UNION SELECT, que não precisa de uma segunda instrução alguma.
Parametrize a query e a string inteira vira apenas o email incomum de alguém, armazenado exatamente como foi digitado.
Para onde isso vai a partir daqui
Três capítulos, três bugs, uma única causa. Nada verificou o que chegava antes de a aplicação agir sobre isso, então um valor escolhido por um estranho acabou decidindo o que o código fazia.
Cada correção até aqui aconteceu no ponto de uso: a propriedade certa para renderização, um limite de tamanho, um placeholder em uma query. Elas são necessárias, mas também chegam tarde: precisam ser repetidas em cada destino, e é fácil esquecer uma delas.
Fundamentos de Zod começa a outra metade da solução: descrever o formato esperado uma única vez, na fronteira, e recusar tudo que não corresponder a ele.

