Dia 13 — SQL injection: o erro que não se comete
O que é SQL injection e como acontece
SQL injection: quando o valor vira sintaxe
O SQL não é uma string. É uma linguagem, com gramática, e o servidor lê a
frase inteira que você mandou. Quando o valor do cliente entra colado no texto,
o cliente passou a escrever a frase junto — e a frase dele é válida.
Isso se chama injeção de SQL, e o nome descreve exatamente o que aconteceu: o
dado foi injetado na frase e virou parte da sintaxe. A forma vulnerável é
sempre a mesma: concatenar no SQL o valor que veio de fora, como a
string no WHERE montada à mão logo abaixo, entre aspas simples. O valor e o
texto são escritos na mesma peça, e o banco não tem como saber onde o dev
acabou de falar e onde o cliente começou.
O exemplo desta aula faz a coisa errada de propósito, contra o banco de teste,
mostra o estrago, e depois faz a coisa certa com o mesmo dado.
O código vulnerável tem aspas no template
const sql = "SELECT id, nm_usuario FROM tb_usuario WHERE nm_usuario = '" + login + "'"; // ^ aspa que o DEV escreveu
Esse detalhe é o que o tutorial escreve e o que faz o ataque funcionar. Sem a
aspa no template, o payload clássico devolve ER_PARSE_ERROR e o código
concatenado parece seguro — foi assim que a primeira versão deste exemplo
falhou em execução.
O ataque, rodando de verdade
O login ' OR '1'='1 produz:
SELECT id, nm_usuario FROM tb_usuario WHERE nm_usuario = '' OR '1'='1'
O WHERE avaliou duas condições, não uma:
| Parte | Quem escreveu | Valor |
|---|---|---|
nm_usuario = '' | o DEV (o template) | texto vazio, não casa com nada |
OR '1'='1' | o atacante | verdadeira sempre |
E o SQL devolve as linhas onde qualquer condição é verdadeira: as três. O
cliente pediu um usuário e recebeu todos, sem erro e sem alerta.
Repara que o payload nem precisa de comentário: ele usa a aspa do template para
fechar o valor e abrir a condição, e a aspa do fim do código fecha a última. **O
ataque fecha sozinho.**
O mesmo código, com ?
const [linhas] = await conexao.query( 'SELECT id, nm_usuario FROM tb_usuario WHERE nm_usuario = ?', [login] );
O mesmo login de ataque devolve 0 linhas. O banco procurou um usuário
chamado literalmente ' OR '1'='1, e não achou — as aspas do payload viraram
texto, não sintaxe. É essa a defesa: o driver manda o valor por um caminho
separado do texto do SQL.
As três saídas que o atacante quer
| Objetivo | Payload | Como funciona |
|---|---|---|
| Ler tudo | ' OR '1'='1 | WHERE vira sempre-verdadeiro |
| Apagar | x; DELETE FROM t WHERE 1=1; -- | ; separa comandos, -- comenta o resto |
| Ler senha | x UNION SELECT ds_senha, nm FROM t -- | UNION junta uma segunda consulta |
A segunda linha é a que muda a natureza do problema: não é mais leitura indevida,
é apagar tabela por engano — o atacante vira o autor de um DELETE que o
servidor executou com a frase que ele escreveu. E é aí que a vulnerabilidade
deixa de ser um problema de consulta e vira um problema de risco: o mesmo
buraco que expõe três senhas também pode apagar a tabela inteira, e o código não
tem como limitar qual dos dois vai acontecer.
Por isso "dados expostos" é a descrição incompleta. O que está em jogo é tudo
que o usuário do banco consegue alcançar com aquele GRANT — e a defesa não é
filtrar payload no código, é não deixar o valor virar sintaxe.
O UNION é o mais caro, e tem uma regra: **as duas consultas precisam devolver o
mesmo número de colunas**. O exemplo mediu as duas formas:
As senhas dos três usuários estão na saída, vazadas pelo mesmo WHERE que o dev
escreveu para filtrar por nome. E o número de colunas do payload é o que o
atendente controla: ele lê o SELECT da aplicação e conta. Por isso SELECT *
também é um alvo.
O estrago sem volta, e o rollback
O exemplo roda a frase destrutiva de verdade, dentro de uma transação que
ele desfaz:
O ; separou os dois comandos e o -- comeu o resto da frase. Sem a transação,
esse era o estado final do banco.
Todo WHERE concatenado é o mesmo buraco
"SELECT ... WHERE nm_usuario = '" + login // <- concatenação "SELECT ... WHERE turma = " + turma // <- concatenação "SELECT ... WHERE vl_saldo > " + minimo // <- concatenação "SELECT ... WHERE dt BETWEEN " + ini + " AND " + fim "SELECT ... ORDER BY " + ordem // <- concatenação, e o pior de todos
As cinco linhas têm a mesma forma: **o texto do SQL cresce com o valor do
cliente**. E todas as cinco aceitam o mesmo payload.
O ORDER BY é o caso mais perigoso porque o valor é um nome de coluna, e nome
de coluna não pode ser parâmetro — mudar o nome muda o plano de execução. É o
que a aula 2 de hoje resolve.
Exemplo
'use strict'; // Exemplo da aula 1 do dia 13: o que acontece quando a string do SQL e concatena. // // O exemplo faz a coisa ERRADA de proposito, contra o banco de teste, e mostra o // estrago. Depois faz a coisa CERTA, com o mesmo dado, e mostra que o estrago nao // existe. E no fim abre transacao de verdade e mostra o `DELETE` passando pelo // `rollback` — que e o unico jeito de desfazer, e a aula 14. // // NADA aqui toca no banco de producao: o `.env` aponta para o banco de teste, e o // exemplo usa tabela com nome proprio. O ataque que ele demonstra e real, e e por // isso que ele roda contra dado descartavel. // // O detalhe que faz a aula funcionar: o codigo vulneravel tem as ASPAS no // template. E o que todo tutorial de SQL injection escreve: // // "SELECT ... WHERE nm_usuario = '" + login + "'" // ^ aspa que o DEV escreveu // // Sem essa aspa no template, o payload classico devolve `ER_PARSE_ERROR` e o // codigo concatenao PARECE seguro. Foi assim que a primeira versao deste exemplo // falhou em execucao. const mysql = require('mysql2/promise'); async function main() { const conexao = await mysql.createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, multipleStatements: true, }); try { await conexao.query('DROP TABLE IF EXISTS tb_d13a1_usuario'); await conexao.query(` CREATE TABLE tb_d13a1_usuario ( id INT AUTO_INCREMENT PRIMARY KEY, nm_usuario VARCHAR(30) NOT NULL, vl_saldo DECIMAL(10,2) NOT NULL DEFAULT 0, ds_senha VARCHAR(60) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await conexao.query( 'INSERT INTO tb_d13a1_usuario (nm_usuario, vl_saldo, ds_senha) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)', ['ana', 150.00, 'hash-ana', 'bruno', 30.00, 'hash-bruno', 'carla', 0.00, 'hash-carla'] ); console.log('--- 3 usuarios no banco de teste ---'); const [todos] = await conexao.query('SELECT id, nm_usuario, vl_saldo FROM tb_d13a1_usuario ORDER BY id'); for (const u of todos) console.log(' ' + u.id + ' | ' + u.nm_usuario + ' | ' + u.vl_saldo); // O codigo vulneravel, exatamente como ele e escrito na internet. // O template esta entre aspas DUPLAS de JavaScript, e por isso o `'` da // aspa de SQL entra sem escape. E a forma como o codigo real e escrito, e a // forma que o payload classico (`' OR '1'='1`) exige para fechar sozinho. const SQL_VULNERAVEL = "SELECT id, nm_usuario FROM tb_d13a1_usuario WHERE nm_usuario = '"; const sqlVuln = (login) => SQL_VULNERAVEL + login + "'"; console.log(' (o template e uma string com aspas DUPLAS em JavaScript, e a aspa de'); console.log(' SQL entra sem escape — e a forma que o payload classico exige)'); // =================================================================== // 1. A FORMA ERRADA, com o login certo // =================================================================== console.log(''); console.log('=== 1. A FORMA ERRADA, com o login certo ==='); const [ok] = await conexao.query(sqlVuln('ana')); console.log("o SQL montado: SELECT id, nm_usuario FROM tb_d13a1_usuario WHERE nm_usuario = 'ana'"); console.log('devolveu ' + ok.length + ' linha(s): ' + ok.map((u) => u.nm_usuario).join(', ')); console.log('ate aqui o codigo parece certo. O erro esta no QUE vem a seguir.'); // =================================================================== // 2. A FORMA ERRADA, com o login de ataque // =================================================================== console.log(''); console.log('=== 2. O MESMO CODIGO, com o login de ataque ==='); const ataque = "' OR '1'='1"; console.log('o que o cliente mandou no campo de login:'); console.log(' ' + JSON.stringify(ataque)); console.log('o SQL que o servidor recebeu, montado pela concatenacao:'); console.log(' ' + sqlVuln(ataque)); console.log(''); const [vazou] = await conexao.query(sqlVuln(ataque)); console.log('o que voltou (' + vazou.length + ' linha(s)):'); for (const u of vazou) console.log(' ' + u.id + ' | ' + u.nm_usuario); console.log(''); console.log('O cliente pediu UM usuario e recebeu TODOS. Sem erro e sem alerta.'); // =================================================================== // 3. Por que funciona: onde cada pedaco foi parar // =================================================================== // // A aspa do template FECHA o valor, e a primeira aspa do payload ABRE outro // valor. O SQL tem sintaxe, e o servidor le a frase inteira como consulta. console.log(''); console.log('=== 3. onde cada pedaco foi parar ==='); console.log('o SQL recebido, e o que o servidor leu em cada parte:'); console.log(" WHERE nm_usuario = '" + "'" + ' OR ' + "'" + '1' + "'" + ' = ' + "'" + '1' + "'"); console.log(' ^^^^^^^^^^^^^^^^^^^^^^ ^^ ^^^^^ ^^^ ^ ^^^^^ ^^^^^'); console.log(' valor escrito pelo DEV OU valor = valor do atacante'); console.log(''); console.log('O `WHERE` avaliou DUAS condicoes, e nao uma:'); console.log(" nm_usuario = ' OR '1'='1' -> o primeiro e texto, e nao casa com nada"); console.log(" '1' = '1' -> verdadeira sempre"); console.log('E o SQL devolve as linhas onde QUALQUER condicao e verdadeira.'); console.log(''); console.log('Repara que o payload nem precisa de comentario: ele usa a propria aspa'); console.log('do template para fechar o valor e abrir a condicao, e a aspa do fim do'); console.log('codigo fecha a ultima. O ataque fecha sozinho.'); // =================================================================== // 4. o mesmo codigo, com o `?` // =================================================================== console.log(''); console.log('=== 4. o MESMO codigo, com o `?` ==='); const [protegido] = await conexao.query( 'SELECT id, nm_usuario FROM tb_d13a1_usuario WHERE nm_usuario = ?', [ataque] ); console.log('o mesmo login de ataque, agora como PARAMETRO:'); console.log(' devolveu ' + protegido.length + ' linha(s)'); console.log(' o banco procurou um usuario chamado literalmente ' + JSON.stringify(ataque) + ','); console.log(' e nao achou. As aspas do payload viraram TEXTO, e nao sintaxe.'); // =================================================================== // 5. as tres saidas do ataque // =================================================================== console.log(''); console.log('=== 5. as tres saidas que o atacante quer ==='); console.log("1. LER TUDO | login: ' OR '1'='1"); console.log(' o WHERE vira sempre-verdadeiro e devolve a tabela inteira'); console.log(''); console.log('2. APAGAR | login: x; DELETE FROM tb_d13a1_usuario WHERE 1=1; -- '); console.log(' o ponto-e-virgula separa comandos e o -- comenta o resto'); console.log(''); console.log('3. LER SENHA | login: x UNION SELECT ds_senha, nm_usuario FROM tb_d13a1_usuario -- '); console.log(' o UNION junta uma segunda consulta, com as colunas que o'); console.log(' atacante escolher'); console.log(''); console.log('O `UNION` e o mais caro, e ele tem uma regra: as duas consultas precisam'); console.log('devolver o MESMO numero de colunas. Com a consulta devolvendo 2, o payload'); console.log('precisa ter 2:'); let umColuna; try { const [r] = await conexao.query(sqlVuln("' UNION SELECT ds_senha FROM tb_d13a1_usuario -- ")); umColuna = 'devolveu ' + r.length + ' linha(s)'; } catch (erro) { umColuna = 'recusado, code ' + erro.code; } console.log(' payload com 1 coluna -> ' + umColuna); const [comColuna] = await conexao.query( sqlVuln("' UNION SELECT ds_senha, nm_usuario FROM tb_d13a1_usuario -- ") ); console.log(' payload com 2 colunas -> devolveu ' + comColuna.length + ' linha(s)'); for (const l of comColuna) { console.log(' ' + l.id + ' | ' + l.nm_usuario + ' <- a coluna primeira agora e a SENHA'); } console.log(' as senhas dos tres usuarios estao ACIMA, vazadas pelo mesmo `WHERE` que'); console.log(' o dev escreveu para filtrar por nome.'); console.log(' E o numero de colunas do payload e o que o atacante controla: ele le o'); console.log(' `SELECT` da aplicacao e conta. Por isso `SELECT *` tambem e um alvo.'); // =================================================================== // 6. o estrago que nao tem volta, e o rollback // =================================================================== // // Aqui a frase destrutiva roda DE VERDADE, dentro de uma transacao que o // exemplo desfaz. E a prova de que o estrago da secao 5 existe: e a unica // forma de mostrar sem destruir o banco. console.log(''); console.log('=== 6. o DELETE de verdade, dentro de uma transacao desfeita ==='); const [antes] = await conexao.query('SELECT COUNT(*) AS n FROM tb_d13a1_usuario'); console.log('usuarios antes:', antes[0].n); await conexao.beginTransaction(); try { await conexao.query( "SELECT id FROM tb_d13a1_usuario WHERE nm_usuario = 'x'; DELETE FROM tb_d13a1_usuario WHERE 1=1; -- x" ); const [dentro] = await conexao.query('SELECT COUNT(*) AS n FROM tb_d13a1_usuario'); console.log('usuarios DENTRO da transacao:', dentro[0].n, '<-- apagou de verdade, agora'); console.log('o `;` separou os dois comandos e o `--` comeu o resto da frase.'); console.log('vou fazer rollback, e o que importa e o que acontece DEPOIS:'); await conexao.rollback(); } catch (erro) { await conexao.rollback(); console.error(' ' + erro.code + ': ' + erro.message); console.log(' o banco recusou a frase:', erro.code); } const [depois] = await conexao.query('SELECT COUNT(*) AS n FROM tb_d13a1_usuario'); console.log('usuarios depois do rollback:', depois[0].n, '<-- voltou tudo'); console.log(''); console.log('O `rollback` devolve a tabela ao estado anterior. E a defesa alem da'); console.log('parametrizacao — e por isso que a aula 14 existe: transacao nao e sintaxe'); console.log('bonita, e o que impede que o estrago vire permanente.'); // =================================================================== // 7. todo WHERE concatenao e o mesmo buraco // =================================================================== console.log(''); // // Cada item abaixo e o TEXTO de uma linha de codigo vulneravel, escrito // dentro de crase para poder mostrar a concatenacao sem executá-la de novo. const exemplosVuln = [ ` "SELECT ... WHERE nm_usuario = ' + login"`, ` "SELECT ... WHERE turma = " + turma`, ` "SELECT ... WHERE vl_saldo > " + minimo`, ` "SELECT ... WHERE dt BETWEEN " + ini + " AND " + fim`, ` "SELECT ... ORDER BY " + ordem`, ]; for (const linha of exemplosVuln) { console.log(linha.padEnd(48) + ' <- concatenao'); } console.log(''); console.log('as cinco linhas acima tem a mesma forma: o TEXTO do SQL cresce com o'); console.log('valor do cliente. E todas as cinco aceitam o mesmo payload.'); console.log(''); console.log('O `ORDER BY` e o caso mais perigoso porque o valor e um NOME DE COLUNA, e'); console.log('nome de coluna nao pode ser parametro. A aula 2 de hoje mostra a saida.'); } finally { await conexao.query('DROP TABLE IF EXISTS tb_d13a1_usuario'); await conexao.end(); console.log(''); console.log('tabela de teste removida; conexao encerrada com end().'); } } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 3 usuarios no banco de teste ---
1 | ana | 150.00
2 | bruno | 30.00
3 | carla | 0.00
(o template e uma string com aspas DUPLAS em JavaScript, e a aspa de
SQL entra sem escape — e a forma que o payload classico exige)
=== 1. A FORMA ERRADA, com o login certo ===
o SQL montado: SELECT id, nm_usuario FROM tb_d13a1_usuario WHERE nm_usuario = 'ana'
devolveu 1 linha(s): ana
ate aqui o codigo parece certo. O erro esta no QUE vem a seguir.
=== 2. O MESMO CODIGO, com o login de ataque ===
o que o cliente mandou no campo de login:
"' OR '1'='1"
o SQL que o servidor recebeu, montado pela concatenacao:
SELECT id, nm_usuario FROM tb_d13a1_usuario WHERE nm_usuario = '' OR '1'='1'
o que voltou (3 linha(s)):
1 | ana
2 | bruno
3 | carla
O cliente pediu UM usuario e recebeu TODOS. Sem erro e sem alerta.
=== 3. onde cada pedaco foi parar ===
o SQL recebido, e o que o servidor leu em cada parte:
WHERE nm_usuario = '' OR '1' = '1'
^^^^^^^^^^^^^^^^^^^^^^ ^^ ^^^^^ ^^^ ^ ^^^^^ ^^^^^
valor escrito pelo DEV OU valor = valor do atacante
O `WHERE` avaliou DUAS condicoes, e nao uma:
nm_usuario = ' OR '1'='1' -> o primeiro e texto, e nao casa com nada
'1' = '1' -> verdadeira sempre
E o SQL devolve as linhas onde QUALQUER condicao e verdadeira.
Repara que o payload nem precisa de comentario: ele usa a propria aspa
do template para fechar o valor e abrir a condicao, e a aspa do fim do
codigo fecha a ultima. O ataque fecha sozinho.
=== 4. o MESMO codigo, com o `?` ===
o mesmo login de ataque, agora como PARAMETRO:
devolveu 0 linha(s)
o banco procurou um usuario chamado literalmente "' OR '1'='1",
e nao achou. As aspas do payload viraram TEXTO, e nao sintaxe.
=== 5. as tres saidas que o atacante quer ===
1. LER TUDO | login: ' OR '1'='1
o WHERE vira sempre-verdadeiro e devolve a tabela inteira
2. APAGAR | login: x; DELETE FROM tb_d13a1_usuario WHERE 1=1; --
o ponto-e-virgula separa comandos e o -- comenta o resto
3. LER SENHA | login: x UNION SELECT ds_senha, nm_usuario FROM tb_d13a1_usuario --
o UNION junta uma segunda consulta, com as colunas que o
atacante escolher
O `UNION` e o mais caro, e ele tem uma regra: as duas consultas precisam
devolver o MESMO numero de colunas. Com a consulta devolvendo 2, o payload
precisa ter 2:
payload com 1 coluna -> recusado, code ER_WRONG_NUMBER_OF_COLUMNS_IN_SELECT
payload com 2 colunas -> devolveu 3 linha(s)
hash-ana | ana <- a coluna primeira agora e a SENHA
hash-bruno | bruno <- a coluna primeira agora e a SENHA
hash-carla | carla <- a coluna primeira agora e a SENHA
as senhas dos tres usuarios estao ACIMA, vazadas pelo mesmo `WHERE` que
o dev escreveu para filtrar por nome.
E o numero de colunas do payload e o que o atacante controla: ele le o
`SELECT` da aplicacao e conta. Por isso `SELECT *` tambem e um alvo.
=== 6. o DELETE de verdade, dentro de uma transacao desfeita ===
usuarios antes: 3
usuarios DENTRO da transacao: 0 <-- apagou de verdade, agora
o `;` separou os dois comandos e o `--` comeu o resto da frase.
vou fazer rollback, e o que importa e o que acontece DEPOIS:
usuarios depois do rollback: 3 <-- voltou tudo
O `rollback` devolve a tabela ao estado anterior. E a defesa alem da
parametrizacao — e por isso que a aula 14 existe: transacao nao e sintaxe
bonita, e o que impede que o estrago vire permanente.
"SELECT ... WHERE nm_usuario = ' + login" <- concatenao
"SELECT ... WHERE turma = " + turma <- concatenao
"SELECT ... WHERE vl_saldo > " + minimo <- concatenao
"SELECT ... WHERE dt BETWEEN " + ini + " AND " + fim <- concatenao
"SELECT ... ORDER BY " + ordem <- concatenao
as cinco linhas acima tem a mesma forma: o TEXTO do SQL cresce com o
valor do cliente. E todas as cinco aceitam o mesmo payload.
O `ORDER BY` e o caso mais perigoso porque o valor e um NOME DE COLUNA, e
nome de coluna nao pode ser parametro. A aula 2 de hoje mostra a saida.
tabela de teste removida; conexao encerrada com end().
Consultas parametrizadas
A parte que o ? não cobre
O ? resolve valor. Ele não resolve nome de coluna, nome de tabela, direção
do ORDER BY, nem curinga de LIKE. E não é falha do driver.
O que o ? realmente protege
A forma certa se chama consulta parametrizada, e a peça que a faz é o ? — o
placeholder, o sinal de interrogação que fica no texto do SQL no lugar do valor.
Executar com parâmetro é passar o valor no segundo argumento, separado do texto:
const [linhas] = await conexao.query( 'SELECT id, nm_usuario FROM tb_usuario WHERE nm_usuario = ?', [login] );
O driver monta um prepared statement: ele manda o texto da consulta ao servidor,
o servidor prepara o plano, e o valor viaja na mensagem seguinte — é o que se
chama bind parameter, o parâmetro que se liga ao ? já preparado. O servidor
nunca lê o valor como texto da frase, e é por isso que a segurança no SQL vem de
graça: não é filtro, é separação de caminho.
A regra que decorre: sempre parametrizar, e não concatenar. Nunca escrever o
valor dentro do texto do SQL — nem escapado à mão, nem "só neste caso".
Medido neste servidor (MariaDB 10.11 + mysql2):
O ? é aceito na posição de coluna. Ele ocupa uma posição de expressão,
e o nome de uma coluna é uma expressão válida ali. Não é que o driver "proíba" —
é que o ? viaja por um caminho separado do texto, e nome de coluna é um valor
legítimo nessa posição.
E é exatamente por isso que o ? não resolve o problema. Ele protege contra
o conteúdo: um ; DROP TABLE vira texto e quebra a consulta, sem executar
nada. Mas um ? numa posição de coluna aceita qualquer expressão —
? com o valor 1 DESC, (SELECT ...) muda a ordem. O driver não interpreta o
texto; o servidor sim. O ? segura valor de dado, não de sintaxe.
A concatenação: o resultado imprevisível
Existe uma função que parece resolver: mysql.escape(valor). Ela devolve o valor
como texto com aspas e escapes corretamente escapados, e é a assinatura dela que
convence — parece que o problema foi tratado.
Não foi. O escape continua sendo concatenação: o texto do SQL cresce com o
valor do cliente, e o que muda é só quantas barras aparecem no resultado. Ele
protege o caso comum e não protege o resto — e o resto é justamente o que o
? resolve por construção. Onde o driver tem uma forma que separa os dois
caminhos, usar escape é aceitar uma forma pior em troca de nada.
O cliente manda id; DROP TABLE tb_d13a2_produto; -- no parâmetro de ordenação.
O SQL concatenado fica:
SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY id; DROP TABLE tb_d13a2_produto; --
O banco devolve ER_PARSE_ERROR. A frase parou no id, que é uma coluna válida;
o resto virou cauda de um item de lista do ORDER BY, e o banco leu `; DROP
TABLE ...` como expressões malformadas.
Nada foi apagado — e é esse o problema. O mesmo payload pode devolver 500,
devolver a lista em ordem errada, ou — com uma palavra a menos, ou com
multipleStatements ligado na outra ponta — apagar a tabela. O código não tem
como saber qual dos três vai acontecer, e não é teste que descobre.
A lista fechada: o código escolhe a coluna
const COLUNAS = { preco: 'vl_preco', nome: 'nm_produto', categoria: 'nm_categoria', estoque: 'qtde', }; const colunaPara = (opcao) => (Object.hasOwn(COLUNAS, opcao) ? COLUNAS[opcao] : COLUNAS.preco); const [linhas] = await conexao.query( 'SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY ' + colunaPara(pedido) + ' ASC LIMIT 3' );
O + continua ali. A diferença é o que ele concatena: não é mais o valor do
cliente, é um nome que o dev escreveu. O cliente pediu "preco" e o código
decidiu qual coluna é essa.
| pedido do cliente | coluna escolhida |
|---|---|
preco | vl_preco |
nome | nm_produto |
categoria | nm_categoria |
id; DROP TABLE ...; -- | vl_preco (o padrão) |
O mesmo pedido de ataque da seção 2, pelo código com lista fechada, devolveu
3 linhas e a tabela continuou de pé com os 5 produtos.
Object.hasOwn fecha a porta lateral
COLUNAS[pedido] sozinho não basta. As chaves __proto__ e constructor
existem no prototype, então passariam pela busca sem nunca estarem no objeto.
O pedido viraria um objeto ou uma função no meio do SQL, e o ORDER BY
receberia undefined — um erro diferente, mais difícil de ler, e igual de
concreto. Por isso o Object.hasOwn e não um pedido in COLUNAS.
A direção: mesma regra
const DIRECOES = { asc: 'ASC', desc: 'DESC' }; const direcaoPara = (d) => (Object.hasOwn(DIRECOES, d) ? DIRECOES[d] : DIRECOES.asc);
| pedido | direção | primeiro preço |
|---|---|---|
asc | ASC | 60.00 |
desc | DESC | 1450.00 |
DESC; SELECT 1 | ASC | 60.00 |
qualquer outra coisa | ASC | 60.00 |
LIKE: o curinga que o cliente escreve
% e _ são curingas do LIKE. Um parâmetro de busca que vai direto para o
LIKE deixa o cliente escrever o curinga:
| busca | linhas | o que aconteceu |
|---|---|---|
cadeira | 2 | o texto exato |
c% | 3 | o cliente mandou o curinga — achou teclado |
%adeira | 2 | o curinga no início |
% | 5 | só o curinga: lista tudo |
Isso é o LIKE funcionando, não uma falha: ninguém foi invadido, o cliente só
pediu um filtro mais largo do que imaginava.
Quando precisa ser impedido, o caminho é o ESCAPE, que declara qual caractere
é o curinga e trata o resto como texto:
const [linhas] = await conexao.query( "SELECT nm_produto FROM tb_d13a2_produto WHERE nm_produto LIKE ? ESCAPE '!'", ['%c!%adeira%'] );
O ! antes do % diz "o próximo caractere é literal" — e o exemplo mediu **0
linhas**: as duas cadeiras sumiram, porque nenhuma tem um % no meio do nome. O
ESCAPE faz parte do SQL, escrito pelo dev, não pelo cliente.
Exemplo
'use strict'; // Exemplo da aula 2 do dia 13: como escrever a parte que o `?` nao cobre. // // O `?` resolve valor. Ele NAO resolve nome de coluna, nome de tabela, direcao // do ORDER BY, nem palavra de LIKE. Nao e falha do driver: e que o `?` viaja // por um caminho separado do texto, e nome de coluna mora no TEXTO do SQL — se // o `?` fosse aceito ali, o plano de execucao mudaria a cada linha. // // A solucao nesses casos nunca e escapar o valor. E uma LISTA FECHADA: o codigo // mapeia o que o cliente pediu para um nome que o DEV escreveu. const mysql = require('mysql2/promise'); async function main() { const conexao = await mysql.createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, }); try { await conexao.query('DROP TABLE IF EXISTS tb_d13a2_produto'); await conexao.query(` CREATE TABLE tb_d13a2_produto ( id INT AUTO_INCREMENT PRIMARY KEY, nm_produto VARCHAR(40) NOT NULL, nm_categoria VARCHAR(20) NOT NULL, vl_preco DECIMAL(10,2) NOT NULL, qtde INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await conexao.query( 'INSERT INTO tb_d13a2_produto (nm_produto, nm_categoria, vl_preco, qtde) VALUES (?,?,?,?), (?,?,?,?), (?,?,?,?), (?,?,?,?), (?,?,?,?)', ['teclado', 'periferico', 150.00, 12, 'mouse', 'periferico', 60.00, 30, 'cadeira', 'mobiliario', 620.00, 5, 'monitor', 'periferico', 890.00, 8, 'cadeira gamer', 'mobiliario', 1450.00, 3] ); console.log('--- 5 produtos de teste ---'); const [p0] = await conexao.query('SELECT nm_produto, nm_categoria, vl_preco FROM tb_d13a2_produto ORDER BY id'); for (const p of p0) console.log(' ' + p.nm_produto.padEnd(14) + ' | ' + p.nm_categoria.padEnd(11) + ' | ' + p.vl_preco); // =================================================================== // 1. Por que o `?` nao aceita nome de coluna // =================================================================== console.log(''); console.log('=== 1. por que o `?` NAO aceita nome de coluna ==='); const sqlOrdem = 'SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY ? LIMIT 2'; try { const [rk] = await conexao.query(sqlOrdem, ['vl_preco']); console.log(' ORDER BY ? , [ "vl_preco" ] -> ACEITO, devolveu ' + rk.length + ' linha(s):'); for (const k of rk) console.log(' ' + k.nm_produto + ' ' + k.vl_preco); } catch (erro) { console.log(' ORDER BY ? -> recusado, code ' + erro.code); } try { await conexao.query('SELECT id FROM tb_d13a2_produto WHERE ? = ?', ['vl_preco', 150]); console.log(' WHERE ? = ? , [ "vl_preco", 150 ] -> ACEITO'); } catch (erro) { console.log(' WHERE ? = ? -> recusado, code ' + erro.code); console.log(' ' + erro.message.split('\n')[0]); } console.log(''); console.log('MEDIDO NESTE SERVIDOR: o `?` no `ORDER BY` e no `WHERE` e ACEITO, e'); console.log('funciona — o `?` vira um item de expressao, como uma coluna ali. Nao e'); console.log('que o driver "proiba": e que o `?` ocupa uma posicao de VALOR, e o nome'); console.log('de uma coluna e um valor valido nessa posicao.'); console.log(''); console.log('E e exatamente por isso que o `?` NAO resolve o problema. O `?` protege'); console.log('contra o CONTEUDO: um `; DROP TABLE` vira texto e quebra a consulta, sem'); console.log('executar nada. Mas um `?` na posicao de coluna aceita QUALQUER'); console.log('EXPRESSAO: `?` com o valor `1 DESC, (SELECT ...)` muda a ordem. O driver'); console.log('nao vai interpretar o texto, e o servidor vai. O `?` segura valor de'); console.log('DADO, e nao valor de SINTAXE — entao coluna e direcao continuam'); console.log('precisando da lista fechada, que e a secao 3.'); // =================================================================== // 2. a concatenacao com nome de coluna: o estrago real // =================================================================== console.log(''); console.log('=== 2. a concatenacao com nome de coluna ==='); const entrada = 'id; DROP TABLE tb_d13a2_produto; -- '; console.log('o cliente mandou no parametro de ordenacao:'); console.log(' ' + JSON.stringify(entrada)); console.log(''); console.log('o SQL que sairia da concatenacao:'); console.log(' SELECT ... FROM tb_d13a2_produto ORDER BY ' + entrada); console.log(''); // O codigo concatenado de verdade, com o mesmo `?` de driver que a secao 1. const sqlCat = 'SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY ' + entrada; console.log('o SQL concatenado, pronto para o servidor:'); console.log(' ' + sqlCat); console.log(''); try { const [r] = await conexao.query(sqlCat); console.log('devolveu ' + r.length + ' linha(s) SEM ERRO.'); } catch (erro) { console.log('recusado, code ' + erro.code); console.log(' ' + erro.message.split('\n')[0]); } console.log(''); console.log('O ponto e o silencio do `ER_PARSE_ERROR`. A frase parou no `id`, que e'); console.log('uma coluna valida: o resto virou cauda de um item de lista do ORDER'); console.log('BY, e o banco leu `; DROP TABLE ...` como expressoes malformadas. Nada'); console.log('foi apagado, e o cliente recebeu um erro 500 em vez de uma lista.'); console.log(''); console.log('Isto e o que torna a concatenacao perigosa na pratica: o resultado'); console.log('imprevisivel. O mesmo payload pode devolver 500, devolver a lista em'); console.log('ordem errada, ou — com uma palavra a menos, ou com `multipleStatements`'); console.log('ligado na outra ponta — apagar a tabela. O codigo nao tem como saber'); console.log('qual dos tres vai acontecer, e nao e teste que descobre.'); // =================================================================== // 3. a LISTA FECHADA: o mapeamento que resolve // =================================================================== // // O codigo abaixo NAO recebe nome de coluna. Ele recebe uma OPCAO ("preco"), // e escolhe sozinho qual nome de coluna usar. O cliente pode pedir o que // quiser: se nao estiver na lista, cai no padrao. console.log(''); console.log('=== 3. a LISTA FECHADA: o codigo escolhe a coluna ==='); const COLUNAS = { preco: 'vl_preco', nome: 'nm_produto', categoria: 'nm_categoria', estoque: 'qtde', }; // `Object.hasOwn` impede que o cliente use `__proto__` ou `constructor` // para sair da lista: o prototype herdado tambem tem chaves. const colunaPara = (opcao) => (Object.hasOwn(COLUNAS, opcao) ? COLUNAS[opcao] : COLUNAS.preco); console.log('o codigo declare, e o DEV que escreve:'); console.log(' preco -> vl_preco'); console.log(' nome -> nm_produto'); console.log(' categoria -> nm_categoria'); console.log(' estoque -> qtde'); console.log(''); for (const pedido of ['preco', 'nome', 'categoria', 'estoque']) { const [r] = await conexao.query( 'SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY ' + colunaPara(pedido) + ' ASC LIMIT 3' ); const primeiro = r.length > 0 ? r[0].nm_produto : '(vazio)'; console.log(" pedido '" + pedido + "' -> coluna '" + colunaPara(pedido) + "' -> primeiro: " + primeiro); } console.log(''); console.log('o `+` ainda esta ali. A diferenca e o que ele concatena: nao e mais o'); console.log('valor do cliente, e um nome que o DEV escreveu. O cliente pediu "preco" e'); console.log('o codigo decidiu qual coluna e essa.'); // =================================================================== // 4. o mesmo pedido, com o payload de ataque // =================================================================== console.log(''); console.log('=== 4. o MESMO codigo, com o payload de ataque ==='); const ataque2 = 'id; DROP TABLE tb_d13a2_produto; -- '; const [r2] = await conexao.query( 'SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY ' + colunaPara(ataque2) + ' ASC LIMIT 3' ); console.log("o cliente pediu: " + JSON.stringify(ataque2)); console.log("o codigo traduziu para a coluna: '" + colunaPara(ataque2) + "' <- o padrao"); console.log('a consulta rodou e devolveu ' + r2.length + ' linha(s). A tabela continua de pe.'); const [contagem] = await conexao.query('SELECT COUNT(*) AS n FROM tb_d13a2_produto'); console.log('a tabela ainda tem ' + contagem[0].n + ' produto(s). O `DROP TABLE` virou'); console.log('texto de um nome de coluna que o codigo nunca usou.'); console.log(''); console.log('E o `Object.hasOwn` e o que fecha a porta lateral do `COLUNAS[pedido]`:'); console.log('sem ele, o pedido `__proto__` ou `constructor` passaria pela busca,'); console.log('porque essas chaves EXISTEM no prototype. O pedido viraria um objeto ou'); console.log('uma funcao no meio do SQL, e o `ORDER BY` receberia `undefined` — um'); console.log('erro diferente, mais dificil de ler, e igual de concreto.'); // =================================================================== // 5. a mesma logica para a DIRECAO // =================================================================== console.log(''); console.log('=== 5. e para a direcao do ORDER BY ==='); const DIRECOES = { asc: 'ASC', desc: 'DESC' }; const direcaoPara = (d) => (Object.hasOwn(DIRECOES, d) ? DIRECOES[d] : DIRECOES.asc); console.log("o codigo aceita 'asc' ou 'desc', e traduz para:"); console.log(" asc -> ASC"); console.log(" desc -> DESC"); console.log(''); for (const pedido of ['asc', 'desc', 'DESC; SELECT 1', 'qualquer outra coisa']) { const [r] = await conexao.query( 'SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY vl_preco ' + direcaoPara(pedido) + ' LIMIT 2' ); console.log(" pedido " + JSON.stringify(pedido).padEnd(24) + ' -> ' + direcaoPara(pedido).padEnd(5) + ' -> ' + r.map((x) => x.vl_preco).join(', ')); } console.log(''); console.log('Um `DESC` com ponto e virgula dentro devolveria `ER_PARSE_ERROR` na'); console.log('forma concatenada. Aqui virou `ASC`, e a consulta rodou.'); // =================================================================== // 6. `LIKE` com `%` do cliente // =================================================================== // // `LIKE` aceita `%` e `_` como curinga. Um parametro de busca que vai direto // pro LIKE deixa o cliente escrever o curinga — e isso NAO e seguranca, e // esperado. O que se faz e escapar o proprio curinga com `ESCAPE`. console.log(''); console.log('=== 6. `LIKE` e o curinga que o cliente escreve ==='); // // O teste que separa os dois: o `%` e um curinga, o `a` nao. A busca `cadeira` // deve achar duas linhas; a busca `c%` deve achar TRES (cadeira, cadeira // gamer, e o que mais comecar com c), e a busca `cadeira%` tambem tres, porque // o `%` casa o resto. const casos = [ ['cadeira', 'sem curinga, o texto exato'], ['c%', 'o cliente mandou o curinga'], ['%adeira', 'o curinga no inicio'], ['%', 'so o curinga: lista tudo'], ]; console.log('cada linha: o que o cliente mandou, e o que o LIKE devolveu'); console.log(''); for (const [texto, nota] of casos) { const [c] = await conexao.query('SELECT nm_produto FROM tb_d13a2_produto WHERE nm_produto LIKE ?', ['%' + texto + '%']); console.log(' ' + texto.padEnd(10) + ' -> ' + String(c.length).padStart(2) + ' linha(s) ' + nota); for (const x of c) console.log(' ' + x.nm_produto); } console.log(''); console.log('O `%` do cliente virou "qualquer sequencia". A busca `c%` devolveu'); console.log('`cadeira gamer` e `cadeira` — linhas que o texto `c` sozinho nao'); console.log('acharia. Isso e o LIKE funcionando, e nao uma falha: ninguem foi'); console.log('invadido, o cliente so pediu um filtro mais largo do que imaginava.'); console.log(''); console.log('Quando isso PRECISA ser impedido, o caminho e o `ESCAPE`, que diz ao'); console.log('qual caractere e o curinga e trata os outros como texto:'); const [esc] = await conexao.query( "SELECT nm_produto FROM tb_d13a2_produto WHERE nm_produto LIKE ? ESCAPE '!'", ['%c!%adeira%'] ); console.log(" LIKE '%c!%adeira%' ESCAPE '!' -> " + esc.length + ' linha(s)'); for (const x of esc) console.log(' ' + x.nm_produto); console.log(''); console.log('o caractere de escape fica logo ANTES do `%`, e o `ESCAPE` diz qual'); console.log('e. O `c!%adeira` procurou a palavra inteira: as duas cadeiras acima'); console.log('sumiram, porque nenhuma tem um `%` no meio do nome. O `ESCAPE` e o'); console.log('unico jeito de dizer "neste LIKE, o curinga e este" — e ele faz parte'); console.log('do SQL, escrito pelo DEV, nao pelo cliente.'); } finally { await conexao.query('DROP TABLE IF EXISTS tb_d13a2_produto'); await conexao.end(); console.log(''); console.log('tabela de teste removida; conexao encerrada com end().'); } } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 5 produtos de teste ---
teclado | periferico | 150.00
mouse | periferico | 60.00
cadeira | mobiliario | 620.00
monitor | periferico | 890.00
cadeira gamer | mobiliario | 1450.00
=== 1. por que o `?` NAO aceita nome de coluna ===
ORDER BY ? , [ "vl_preco" ] -> ACEITO, devolveu 2 linha(s):
teclado 150.00
mouse 60.00
WHERE ? = ? , [ "vl_preco", 150 ] -> ACEITO
MEDIDO NESTE SERVIDOR: o `?` no `ORDER BY` e no `WHERE` e ACEITO, e
funciona — o `?` vira um item de expressao, como uma coluna ali. Nao e
que o driver "proiba": e que o `?` ocupa uma posicao de VALOR, e o nome
de uma coluna e um valor valido nessa posicao.
E e exatamente por isso que o `?` NAO resolve o problema. O `?` protege
contra o CONTEUDO: um `; DROP TABLE` vira texto e quebra a consulta, sem
executar nada. Mas um `?` na posicao de coluna aceita QUALQUER
EXPRESSAO: `?` com o valor `1 DESC, (SELECT ...)` muda a ordem. O driver
nao vai interpretar o texto, e o servidor vai. O `?` segura valor de
DADO, e nao valor de SINTAXE — entao coluna e direcao continuam
precisando da lista fechada, que e a secao 3.
=== 2. a concatenacao com nome de coluna ===
o cliente mandou no parametro de ordenacao:
"id; DROP TABLE tb_d13a2_produto; -- "
o SQL que sairia da concatenacao:
SELECT ... FROM tb_d13a2_produto ORDER BY id; DROP TABLE tb_d13a2_produto; --
o SQL concatenado, pronto para o servidor:
SELECT nm_produto, vl_preco FROM tb_d13a2_produto ORDER BY id; DROP TABLE tb_d13a2_produto; --
recusado, code ER_PARSE_ERROR
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'DROP TABLE tb_d13a2_produto; --' at line 1
O ponto e o silencio do `ER_PARSE_ERROR`. A frase parou no `id`, que e
uma coluna valida: o resto virou cauda de um item de lista do ORDER
BY, e o banco leu `; DROP TABLE ...` como expressoes malformadas. Nada
foi apagado, e o cliente recebeu um erro 500 em vez de uma lista.
Isto e o que torna a concatenacao perigosa na pratica: o resultado
imprevisivel. O mesmo payload pode devolver 500, devolver a lista em
ordem errada, ou — com uma palavra a menos, ou com `multipleStatements`
ligado na outra ponta — apagar a tabela. O codigo nao tem como saber
qual dos tres vai acontecer, e nao e teste que descobre.
=== 3. a LISTA FECHADA: o codigo escolhe a coluna ===
o codigo declare, e o DEV que escreve:
preco -> vl_preco
nome -> nm_produto
categoria -> nm_categoria
estoque -> qtde
pedido 'preco' -> coluna 'vl_preco' -> primeiro: mouse
pedido 'nome' -> coluna 'nm_produto' -> primeiro: cadeira
pedido 'categoria' -> coluna 'nm_categoria' -> primeiro: cadeira gamer
pedido 'estoque' -> coluna 'qtde' -> primeiro: cadeira gamer
o `+` ainda esta ali. A diferenca e o que ele concatena: nao e mais o
valor do cliente, e um nome que o DEV escreveu. O cliente pediu "preco" e
o codigo decidiu qual coluna e essa.
=== 4. o MESMO codigo, com o payload de ataque ===
o cliente pediu: "id; DROP TABLE tb_d13a2_produto; -- "
o codigo traduziu para a coluna: 'vl_preco' <- o padrao
a consulta rodou e devolveu 3 linha(s). A tabela continua de pe.
a tabela ainda tem 5 produto(s). O `DROP TABLE` virou
texto de um nome de coluna que o codigo nunca usou.
E o `Object.hasOwn` e o que fecha a porta lateral do `COLUNAS[pedido]`:
sem ele, o pedido `__proto__` ou `constructor` passaria pela busca,
porque essas chaves EXISTEM no prototype. O pedido viraria um objeto ou
uma funcao no meio do SQL, e o `ORDER BY` receberia `undefined` — um
erro diferente, mais dificil de ler, e igual de concreto.
=== 5. e para a direcao do ORDER BY ===
o codigo aceita 'asc' ou 'desc', e traduz para:
asc -> ASC
desc -> DESC
pedido "asc" -> ASC -> 60.00, 150.00
pedido "desc" -> DESC -> 1450.00, 890.00
pedido "DESC; SELECT 1" -> ASC -> 60.00, 150.00
pedido "qualquer outra coisa" -> ASC -> 60.00, 150.00
Um `DESC` com ponto e virgula dentro devolveria `ER_PARSE_ERROR` na
forma concatenada. Aqui virou `ASC`, e a consulta rodou.
=== 6. `LIKE` e o curinga que o cliente escreve ===
cada linha: o que o cliente mandou, e o que o LIKE devolveu
cadeira -> 2 linha(s) sem curinga, o texto exato
cadeira
cadeira gamer
c% -> 3 linha(s) o cliente mandou o curinga
teclado
cadeira
cadeira gamer
%adeira -> 2 linha(s) o curinga no inicio
cadeira
cadeira gamer
% -> 5 linha(s) so o curinga: lista tudo
teclado
mouse
cadeira
monitor
cadeira gamer
O `%` do cliente virou "qualquer sequencia". A busca `c%` devolveu
`cadeira gamer` e `cadeira` — linhas que o texto `c` sozinho nao
acharia. Isso e o LIKE funcionando, e nao uma falha: ninguem foi
invadido, o cliente so pediu um filtro mais largo do que imaginava.
Quando isso PRECISA ser impedido, o caminho e o `ESCAPE`, que diz ao
qual caractere e o curinga e trata os outros como texto:
LIKE '%c!%adeira%' ESCAPE '!' -> 0 linha(s)
o caractere de escape fica logo ANTES do `%`, e o `ESCAPE` diz qual
e. O `c!%adeira` procurou a palavra inteira: as duas cadeiras acima
sumiram, porque nenhuma tem um `%` no meio do nome. O `ESCAPE` e o
unico jeito de dizer "neste LIKE, o curinga e este" — e ele faz parte
do SQL, escrito pelo DEV, nao pelo cliente.
tabela de teste removida; conexao encerrada com end().