Skip to content
This page has been auto-translated and may contain errors.View in English

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çaO que faz
front-end/index.htmlA marcação do formulário: campos, um botão de envio, containers para mensagens de sucesso e erro.
front-end/app.tsLê os campos, envia para o back end, mostra o resultado.
front-end/types.tsEspelha os tipos do back end para que o cliente saiba o formato de uma resposta.
back-end/server.tsInicializa o Express, faz o parse do JSON, monta as rotas, escuta na porta 3000.
back-end/routes/auth.tsO endpoint /api/register-vulnerable. Ele pula completamente a validação e devolve a entrada bruta.
back-end/state/mockDb.tsUm 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:

Vulnerable
ts
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.

JunoO formulário que esta seção ataca Esse pequeno formulário é toda a superfície de ataque da seção. Ele permanece o mesmo do início ao fim, e cada capítulo o quebra de um jeito novo antes de consertá-lo.

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.

JunoO formulário que esta seção ataca Um endpoint, um formulário e alguns campos já são superfície suficiente para injeção de script, esgotamento de recursos e manipulação de consultas. Esse é o choque útil: não é preciso um app grande para estar quebrado em três direções ao mesmo tempo.

Aplicações reais têm centenas desses casos. O hábito que vale a pena construir é ler qualquer handler que toque em req.body e se perguntar o que ele está assumindo sobre o que chegou.

JunoO formulário que esta seção ataca Uma superfície tão pequena é honesta sobre o caminho da entrada, mas discretamente enganosa sobre o tempo. O banco de dados simulado permite que um ataque seja demonstrado e reiniciado em um segundo, e é exatamente por isso que o foco permanece em para onde os valores viajam.

O que ela esconde é a categoria de bug que você vai encontrar mais adiante. O armazenamento é onde a entrada fica esperando.

Um valor que parece inofensivo ao entrar pode se tornar perigoso meses depois, quando uma página diferente o renderiza em um contexto diferente. Nenhuma quantidade de atenção a este formulário vai mostrar isso.

Guarde essa ideia enquanto tudo ainda parece stateless (sem estado).

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:

html
<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á.

JunoO navegador não pode impor nada A regra é sobre quem está no controle. Qualquer coisa rodando no navegador de alguém está rodando no computador dessa pessoa, sob o controle dela, e ela pode alterar isso.

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.

JunoO navegador não pode impor nada Uma forma rápida de sentir isso na prática: abra a aba de rede, envie o formulário uma vez, copie a requisição como curl, depois edite o corpo e envie de novo. Sem página, sem JavaScript, sem validação.

Se essa requisição for aceita com valores que o formulário teria rejeitado, você encontrou a brecha entre a interface e o endpoint.

JunoO navegador não pode impor nada A validação no lado do cliente ainda tem seu valor. Ela evita idas e vindas desnecessárias, dá retorno imediato, e mantém a experiência de erro perto do campo.

O erro está em tratá-la como uma das suas camadas de segurança. Ela fica fora da fronteira de confiança, a linha entre a parte do sistema que você controla e a parte que você não controla. Por isso ela entra no orçamento de usabilidade, e nunca no de segurança.

Uma equipe que a conta como um controle costuma descobrir o erro através de um relatório de incidente.

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 terminaO que pode dar erradoCapítulo
Renderizada em uma páginaScript injetado por um visitante roda no navegador de outro visitanteCross-site scripting
Consumindo recursos do servidorUma requisição gigante ou elaborada esgota memória, armazenamento ou conexõesNegação de serviço
Concatenada em uma consultaA entrada vira SQL e lê, altera ou apaga dadosInjeçã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.

JunoTrês caminhos pelos quais a entrada vira um bug A pergunta que mantém isso simples: para onde esse valor vai a seguir?

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.

JunoTrês caminhos pelos quais a entrada vira um bug Acompanhe um campo do início ao fim antes de ler os capítulos de ataque. Pegue o campo de nome: ele é lido em app.ts, enviado ao endpoint, armazenado, devolvido, e renderizado na mensagem de sucesso.

Cinco lugares. Cada um é uma chance de o valor ser interpretado em vez de exibido.

JunoTrês caminhos pelos quais a entrada vira um bug Esses três são os casos demonstráveis, e o padrão por trás deles é mais amplo. Sempre que um texto passa de dado para linguagem, quem interpreta do outro lado decide o que ele significa: HTML, SQL, um comando de shell, um template, um caminho de arquivo.

Reconhecer esse padrão importa mais do que decorar payloads, porque os payloads mudam e o padrão nã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:

  1. Validação do navegador pega erros de digitação e economiza uma ida e volta. Não impede ninguém determinado.
  2. 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.
  3. Saída segura escapa valores no momento em que são renderizados, para que o texto armazenado continue sendo texto.
  4. 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.

JunoNenhuma correção única, várias camadas Camadas são a versão de segurança de não colocar todos os ovos na mesma cesta.

Você ainda vai errar em uma delas de vez em quando. Ter quatro é o que torna esse erro isolado algo sobrevivível.

JunoNenhuma correção única, várias camadas As camadas fazem trabalhos diferentes, então uma não substitui a outra. A validação decide se aceita um valor. O escape decide como renderizá-lo com segurança. A parametrização decide como enviá-lo a um banco de dados.

Um schema que aceita só nomes razoáveis não torna uma consulta concatenada segura, e uma consulta parametrizada não torna esse mesmo valor seguro dentro de uma página.

A segunda camada vale a pena resolver com uma biblioteca, em vez de verificações feitas na mão. Um schema te dá uma única fonte de verdade em vez de regras espalhadas entre atributos HTML e código do handler.

Você também ganha mensagens de erro que você controla em vez das genéricas do navegador, espaço para regras de negócio que o navegador não tem como expressar, e a mesma definição utilizável tanto no servidor quanto na página.

JunoNenhuma correção única, várias camadas Onde uma camada fica posicionada importa tanto quanto o fato de ela existir. A validação pertence à fronteira, antes da lógica de negócio, para que nada mais adiante precise se perguntar o que recebeu. O escape pertence à saída, porque só quem renderiza sabe o contexto de destino.

Escapar na entrada é o erro clássico. Isso armazena dados corrompidos, quebra assim que um segundo destino aparece, e te deixa com um banco de dados cheio de &amp;amp; que ninguém consegue desfazer com segurança. Valide na entrada, escape na saída.

Três palavras são usadas como se significassem a mesma coisa, mas nomeiam decisões diferentes:

  • Validação pergunta se um valor é aceitável e o rejeita se não for. Uma idade de -4 falha.
  • Sanitização edita o valor para remover partes indesejadas, e mantém o que sobra.
  • Escape deixa o valor intacto e o codifica para um destino específico, transformando < em &amp;lt; ao entrar no HTML.

Recorra à validação primeiro. Rejeitar um valor ruim é mais fácil de raciocinar do que consertá-lo, e a sanitização é o último recurso: todo filtro que remove conteúdo perigoso está apostando no que significa "perigoso".

Coloque em prática

Aqui está o handler de cadastro novamente:

Vulnerable
ts
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:

  1. O que ele assume sobre os valores em req.body?
  2. Quais dessas suposições ainda valeriam se a requisição viesse do curl em vez do formulário?
  3. 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.