Nunca confie na entrada do usuário
Um formulário de cadastro é praticamente o exemplo mais comum de desenvolvimento web. Alguns campos de texto, um botão de envio, uma mensagem quando dá certo.
Ele também é a porta de entrada. Cada nome de usuário, endereço de e-mail, comentário e busca que uma pessoa digita é dado que sua aplicação não escreveu, vindo de uma máquina que você não controla.
Boa parte disso é inofensiva. Mas basta um visitante curioso para ver o que acontece quando digita algo que você não previu.
Tratar a entrada do usuário como dado seguro é a suposição mais cara de todo o desenvolvimento web.
O formulário que esta seção ataca
O exemplo usado ao longo da seção é um formulário de cadastro com front end e back end, construído deliberadamente de forma ruim.
| Peça | O que faz |
|---|---|
front-end/index.html | A marcação do formulário: campos, um botão de envio, containers para mensagens de sucesso e erro. |
front-end/app.ts | Lê os campos, envia para o back end, mostra o resultado. |
front-end/types.ts | Espelha os tipos do back end para que o cliente saiba o formato de uma resposta. |
back-end/server.ts | Inicializa o Express, faz o parse do JSON, monta as rotas, escuta na porta 3000. |
back-end/routes/auth.ts | O endpoint /api/register-vulnerable. Ele pula completamente a validação e devolve a entrada bruta. |
back-end/state/mockDb.ts | Um substituto para um banco de dados, para que a contagem de cadastros possa mudar sem um. |
O nome desse endpoint já é um aviso. Nada nele deveria estar em produção. Ele existe só para que os ataques dos próximos capítulos tenham onde aterrissar.
Reduzido ao essencial, o handler faz isto:
router.post('/api/register-vulnerable', (req, res) => {
const { name, email, password } = req.body
mockDb.users.push({ name, email, password })
res.json({ success: true, user: { name, email } })
})Três campos chegam, três campos são armazenados, dois são devolvidos. Nada no meio do caminho pergunta se name é mesmo um nome, se email tem um @, ou qual é o tamanho de cada um deles.
Trabalhar com um app deliberadamente quebrado parece estranho no começo. Ele foi construído errado de propósito, então a falha fica visível bem na sua frente.
O navegador não pode impor nada
O HTML te dá validação de graça. Marque um campo como required e o formulário não será enviado vazio. Defina type="email" e o navegador verifica se o valor parece um endereço.
Isso é útil, e não protege ninguém.
Uma pessoa que quer contornar isso pode desligar o JavaScript, editar os atributos nas ferramentas de desenvolvedor, ou simplesmente pular a página e enviar a requisição direto para o endpoint com curl. Seu formulário é um cliente. O endpoint aceita requisições de qualquer coisa.
As verificações do navegador são uma cortesia para usuários honestos
Elas pegam erros de digitação e economizam uma ida e volta. Não são um controle de segurança, porque quem decide se vai executá-las é o próprio atacante.
Por isso o formulário começa com a validação do navegador desligada:
<form novalidate>Começar do zero torna o ponto difícil de esquecer. Toda verificação que importa é adicionada deliberadamente, no servidor, onde a requisição não pode ser editada no caminho até chegar lá.
O servidor roda na sua máquina. Esse é o único lugar onde uma verificação não pode ser desligada pela pessoa que você está verificando.
Três caminhos pelos quais a entrada vira um bug
Os próximos três capítulos seguem o mesmo valor até três destinos diferentes, e é o destino que decide o que dá errado.
| Onde a entrada termina | O que pode dar errado | Capítulo |
|---|---|---|
| Renderizada em uma página | Script injetado por um visitante roda no navegador de outro visitante | Cross-site scripting |
| Consumindo recursos do servidor | Uma requisição gigante ou elaborada esgota memória, armazenamento ou conexões | Negação de serviço |
| Concatenada em uma consulta | A entrada vira SQL e lê, altera ou apaga dados | Injeção de SQL |
O mesmo campo pode alimentar os três. Um nome vai para uma página, para o armazenamento e para uma consulta, então um único valor não verificado tem três formas de te prejudicar.
Para o HTML de uma página, para o banco de dados, para uma consulta, para um e-mail? Cada destino tem sua própria forma de interpretar erroneamente um texto como uma instrução.
Nenhuma correção única, várias camadas
Cada vulnerabilidade tem uma defesa, e nenhuma delas é a resposta completa. O termo para empilhá-las é defesa em profundidade: assuma que qualquer camada pode falhar, e garanta que algo atrás dela ainda resista.
Para este formulário, as camadas são:
- Validação do navegador pega erros de digitação e economiza uma ida e volta. Não impede ninguém determinado.
- Validação de schema no servidor decide se a requisição sequer tem o formato certo antes de um handler tocá-la. Mais adiante na seção, isso vira o Zod, uma biblioteca para descrever o formato que você espera e verificar dados contra ele.
- Saída segura escapa valores no momento em que são renderizados, para que o texto armazenado continue sendo texto.
- Consultas parametrizadas mantêm a estrutura de uma consulta separada dos dados que entram nela.
Um ataque precisa passar pelas quatro. Um erro em uma delas é um bug; um erro em uma delas sem nenhuma outra camada é um incidente.
Você ainda vai errar em uma delas de vez em quando. Ter quatro é o que torna esse erro isolado algo sobrevivível.
Coloque em prática
Aqui está o handler de cadastro novamente:
router.post('/api/register-vulnerable', (req, res) => {
const { name, email, password } = req.body
mockDb.users.push({ name, email, password })
res.json({ success: true, user: { name, email } })
})Responda a três perguntas sobre ele:
- O que ele assume sobre os valores em
req.body? - Quais dessas suposições ainda valeriam se a requisição viesse do
curlem vez do formulário? - Para onde cada campo viaja depois que o handler o recebe?
Compare suas respostas
1. O que ele assume. Cinco coisas, nenhuma delas verificada:
- Os três campos chegaram.
- Cada um é uma string.
- Nenhum é longo demais.
emailé de fato um e-mail.- Nenhum deles será interpretado como instrução por quem lidar com eles a seguir.
Cada uma dessas é uma esperança.
2. O que sobrevive ao curl. Nenhuma delas. A única coisa que impunha qualquer uma dessas condições era a marcação do próprio formulário, e uma requisição enviada direto ao endpoint nunca passa perto do formulário. O handler se comporta de forma idêntica nos dois casos, porque não consegue perceber a diferença.
3. Para onde os campos viajam. name e email vão para o armazenamento, depois voltam na resposta, que o front end renderiza na mensagem de sucesso. Um único envio, e cada valor já chegou tanto a uma página quanto a um banco de dados.
password vai para o armazenamento exatamente como foi digitada, em texto puro, o que é um bug à parte: ela deveria ser criptografada (hash) e nunca mantida em uma forma legível. Troque o armazenamento simulado por um banco de dados real e as três também chegarão a uma consulta.
Essas são as suposições que os próximos três capítulos vão quebrar.
O que vem a seguir
O formulário está pronto, o navegador já não finge mais protegê-lo, e todo valor que ele aceita é, no momento, confiado sem questionamento.
Cross-site scripting inicia a demolição, com um campo de nome que executa código no navegador de outra pessoa.

