Disponível para novos projetos Acra, Gana
Todos os textos

Cibersegurança · Modelagem de ameaças · Backend · Cloudflare Workers

Modelagem de ameaças de um formulário de contato: defesa em profundidade em uma API muito pequena

Um formulário de contato parece a característica mais entediante em um site. Ele também é um endpoint público que aceita texto de estranhos e o envia para um banco de dados e uma caixa de entrada. Aqui está como eu modeleei as ameaças do formulário deste site, camada por camada, e onde ele ainda é fraco.

Por Festus Boamah · · 6 min read

O formulário de contato deste site tem cerca de quarenta linhas de formulário e uma rota de API. É fácil tratá-lo como um detalhe. Mas olhe para ele do outro lado: é um endpoint público, aberto a toda a internet, que aceita texto de estranhos e o escreve em um banco de dados e uma caixa de entrada de email. É exatamente o tipo de característica que os atacantes procuram, porque ninguém o observa.

Então, antes de lançá-lo, sentei-me e modeleei as ameaças. Este post percorre esse processo: o que pode dar errado, o que eu construí contra cada ameaça e, igualmente importante, onde o design ainda é fraco.

Diagrama do fluxo de dados do formulário de contato: o navegador do visitante envia JSON através de uma fronteira de confiança para um Cloudflare Worker, que aplica seis camadas (validação, honeypot, limite de taxa, Turnstile, SQL parametrizado, e-mail seguro de cabeçalho) antes de armazenar a mensagem em D1 e enviar um e-mail de notificação

Comece com o fluxo de dados

A modelagem de ameaças começa com o desenho de como os dados se movem. O meu é curto:

  1. Um visitante preenche o formulário no navegador.
  2. O navegador envia JSON para POST /api/contact, um pequeno aplicativo Hono em execução em Cloudflare Workers.
  3. O Worker valida a entrada, verifica abusos e armazena a mensagem em um banco de dados D1 (SQLite).
  4. O Worker envia um email de notificação para mim, com o endereço do visitante como Reply-To.

Cada seta nessa lista é uma fronteira de confiança: navegador para API, API para banco de dados, API para email. Cada uma delas é um lugar onde a entrada não confiável cruza para algo que eu cuido. Para cada fronteira, fiz as perguntas por trás do modelo STRIDE: alguém pode simular algo, manipular os dados, negar o que fez, divulgar informações, negar serviço ou elevação de privilégios?

Para um formulário de contato, três ameaças se destacam: abuso (spam e inundação), injeção (no banco de dados e no email) e privacidade (agora estou segurando nomes, emails e palavras de outras pessoas).

Camada 1: suponha que o navegador está mentindo

A primeira regra é que o formulário no navegador é uma sugestão, não um controle. Qualquer pessoa pode pular e enviar solicitações com curl. Então, todas as verificações acontecem novamente no servidor:

  • O corpo deve ser JSON válido, ou a solicitação é rejeitada com um 400.
  • name deve estar presente e ter no máximo 100 caracteres.
  • email deve corresponder a um padrão básico e ter no máximo 200 caracteres.
  • intent deve ser um de uma lista fixa (project, design, teaching, speaking, other). Isso é uma lista de permissões: não tento adivinhar como a entrada ruim parece, apenas aceito o que a entrada boa parece.
  • message deve ter entre 10 e 5.000 caracteres.

Os limites de comprimento não são apenas uma questão de organização. Eles estabelecem um teto para a quantidade de armazenamento e e-mail que uma solicitação pode consumir, o que importa mais tarde quando falamos sobre inundações.

A mesma ideia se aplica ao CORS. A API só retorna cabeçalhos CORS para origens em uma lista de permissões (meu domínio e suas implantações de pré-visualização). Mas não conto o CORS como um controle de segurança aqui. Ele impede que outros sites usem o navegador de um visitante para chamar minha API; não faz nada contra um script que fala com a API diretamente. Saber qual controle protege contra qual atacante é metade do trabalho.

Camada 2: tornar o spam caro

O spam é o ataque mais provável a qualquer formulário de contato, então ele recebe três camadas separadas. Nenhuma delas é suficiente sozinha; juntas, elas aumentam o custo.

Um armadilha. O formulário tem um campo oculto company que as pessoas nunca veem e nunca preenchem. Bots simples preenchem todos os campos. Se company tiver um valor, a API retorna { ok: true } e joga a mensagem fora silenciosamente. Retornar sucesso é deliberado: um erro ensinaria ao autor do bot que ele foi pego.

Um limite de taxa. Cada endereço IP recebe cinco envios por hora, contados em Cloudflare KV com um vencimento de uma hora. Além disso, a API retorna 429 Too Many Requests. A contagem é feita antes da verificação do bot, então um script que continua falhando ainda consome sua própria cota.

Um desafio. O formulário usa Cloudflare Turnstile, e o servidor verifica o token com Cloudflare antes de armazenar qualquer coisa. A chave secreta vive em um segredo do Worker, nunca no repositório ou na página.

Isso é defesa em profundidade em pequena escala: a armadilha pega bots preguiçosos de graça, Turnstile pega a maioria do resto, e o limite de taxa limita os danos de qualquer coisa que passe por ambos.

Camada 3: injeção, em dois lugares que as pessoas esquecem

A maioria dos desenvolvedores sabe sobre injeção SQL. O insert em D1 usa uma consulta parametrizada:

await c.env.DB.prepare('INSERT INTO messages (name, email, intent, body) VALUES (?, ?, ?, ?)')
  .bind(name, email, intent, message)
  .run();

O texto do visitante é passado como dados, nunca colado na string SQL, então uma mensagem contendo '); DROP TABLE messages; -- é armazenada exatamente como isso: uma mensagem estranha.

O segundo ponto de injeção é menos famoso: cabeçalhos de e-mail. O e-mail de notificação é construído manualmente como uma mensagem MIME, e o nome e e-mail do visitante acabam nos cabeçalhos Subject e Reply-To. Os cabeçalhos de e-mail são separados por quebras de linha. Se o nome de um visitante contivesse uma quebra de linha seguida de Bcc: [email protected], uma implementação descuidada adicionaria um novo cabeçalho e transformaria meu servidor em um relé de spam. Então, todo valor que vai para um cabeçalho passa por uma função que substitui quebras de linha por espaços primeiro, e nomes não ASCII (como Kɔfi ou 李明) são codificados corretamente em vez de serem passados crus.

O corpo do e-mail é texto simples, não HTML, então uma mensagem cheia de <script> tags é apenas texto quando eu leio.

Camada 4: os cabeçalhos chatos que fazem muito

O site em si é servido com cabeçalhos de segurança estritos: uma Política de Segurança de Conteúdo que só permite scripts do meu próprio domínio e dos dois serviços Cloudflare que eu uso, frame-ancestors 'none' e X-Frame-Options: DENY contra clickjacking, HSTS para que os navegadores sempre usem HTTPS, e um Permissions-Policy que desativa a câmera, microfone e localização. Nada disso é emocionante, e esse é o ponto. A boa segurança é principalmente decisões chatas tomadas consistentemente.

Onde ainda é fraco

Um modelo de ameaça que não encontra fraquezas não é um modelo de ameaça. Estas são as que eu sei:

  • Limites de taxa baseados em IP punem redes compartilhadas. Muitas redes móveis colocam um grande número de usuários atrás de um pequeno conjunto de endereços IP compartilhados (NAT de grau de transportadora). Em uma rede como essa, um usuário abusivo poderia usar o limite horário para pessoas inocentes que acontecem de compartilhar o mesmo endereço. Para um site pessoal, a compensação é aceitável, mas é um problema real de justiça para serviços construídos para usuários móveis africanos, e é algo que eu quero pensar mais sobre.
  • O limite de taxa é aproximado. O KV é eventualmente consistente, e ler e, em seguida, escrever o contador não é atômico, então um surto rápido de solicitações paralelas pode passar ligeiramente pelo limite. Isso é aceitável para amortecer spam; não seria aceitável para, digamos, limitar tentativas de login, onde eu usaria um armazenamento fortemente consistente.
  • 'unsafe-inline' ainda está na política de script. O site estático usa alguns scripts inline, então a CSP permite isso. Isso enfraquece a proteção da CSP contra scripting entre sites. A solução adequada é migrar para hashes ou nonces para cada script inline.
  • Mensagens são mantidas indefinidamente, em texto puro. No momento, não há política de retenção. De acordo com a Lei de Proteção de Dados de Gana, 2012 (Lei 843) e boas práticas em todos os lugares, eu só deveria manter dados pessoais pelo tempo que eu precisar. Um trabalho agendado que exclui mensagens após um período fixo é o próximo passo óbvio.

Eu classifiquei esses quatro e escrevi como planejo corrigir cada um deles em uma postagem de follow-up.

O que eu tirei disso

Duas coisas ficaram comigo.

Em primeiro lugar, a segurança começa com a pergunta “quem está do outro lado deste endpoint?”, e não com uma lista de ferramentas. Turnstile, KV e consultas parametrizadas são úteis apenas porque o modelo de ameaça disse onde colocá-los.

Em segundo lugar, os recursos pequenos e sem glamour são onde o risco se esconde. Ninguém testa a forma de contato. São precisamente os sistemas que parecem pequenos demais para importar, um portal de resultados de uma escola, uma página de doações de uma igreja, um formulário de inscrição de uma pequena ONG, que mantêm dados reais de pessoas com a menor proteção. São esses os sistemas que eu mais quero tornar mais seguros, e são onde eu quero que minha pesquisa vá.

Se você notar algo que eu perdi, eu realmente gostaria de saber: entre em contato. Sim, através do formulário.

Festus Boamah

Vamos trabalhar juntos

Vamos construir algo com significado.

Tem um produto para construir, uma marca para moldar ou uma aula para dar? Adoraria saber.