Blog / Engenharia / No.008

Detectar um formulário de contato sem quebrar nada

Uma porta é uma rota de contato pública utilizável. Achar uma em um site qualquer é arqueologia de plataforma, uma função de pontuação, e duas travas que precisam falhar fechando.

Mohamad · Knockwire16 de junho de 202612 min de leituraEngenharia

Este texto é uma tradução do original em inglês.

Detecção de formulário de contato soa como problema resolvido até você apontar isso para mil sites de empresas. As falhas não são exóticas. O formulário está lá e o parser não enxerga. O parser vê nove campos e escreve a mensagem no errado. A trava que deveria nos parar em um formulário protegido lê uma propriedade que não existe, recebe undefined de volta, e libera tudo. Chamamos de porta uma rota de contato pública utilizável, e é isto que achar uma envolve de verdade.

01A detecção de formulário de contato começa pela plataforma

A boa notícia primeiro: formulários de contato não são uma cauda longa. Entre as empresas que lemos, cinco coisas respondem pela maior parte do que aparece. HubSpot, ActiveCampaign, WPForms, Typeform, e um formulário HTML puro postando de volta para o site em que ele mora. Identifique a plataforma primeiro, depois rode o extrator escrito para ela. Uma passada genérica que coleta todo input da página erra pelo menos três das cinco, e erra sem levantar nada.

  • HubSpot: a página carrega um portal id e um form guid, e nada do que você envia vai para a página que você está olhando. O envio é um POST JSON para um endpoint regional chaveado pelo portal, e a região faz parte do endereço em vez de ser um header, então um portal europeu enviado para o host padrão não é aceito por ninguém. A lista de campos vem da definição do formulário, não do DOM.
  • ActiveCampaign: o embed é uma tag de script, e a marcação vive dentro de uma string JavaScript, com escape duplo. Sinais de menor e maior chegam como escapes unicode e toda aspa vem com barra invertida. Um parser de HTML apontado para aquela página não acha formulário nenhum, porque naquele momento não existe um. Desfaça o escape da string, parseie o resultado, e extraia do resultado.
  • WPForms: os nomes são posicionais. Um input se chama wpforms[fields][3] e o 3 é um id de linha no banco WordPress de outra pessoa, então o nome não carrega significado. Quem carrega é o texto do label e o atributo de tipo do campo.
  • Typeform: não existe formulário na página. Existe um fluxo hospedado descrito como blocos JSON, e a leitura honesta é que essa não é uma porta que possamos usar.
  • HTML puro: um action, um method, e normalmente um token oculto que precisa ser devolvido junto com o cookie de sessão que o emitiu. Buscar e enviar precisam compartilhar o mesmo pote de cookies ou o post é rejeitado como falsificação, que é o comportamento correto da parte do site.

Cada um desses é um contrato de envio, e não uma peculiaridade de renderização. O extrator de um não é uma versão levemente ajustada do extrator de outro, e fingir que é produz um detector que reporta sucesso em quase toda página e uma taxa de entrega que não concorda com ele.

02O mapeamento de campos é onde fica difícil

Digamos que o formulário foi achado e parseado. Você agora tem entre quatro e onze inputs e uma mensagem para colocar. Qual caixa é a mensagem?

Pontuamos cada input por nome, id, placeholder, texto do label associado, aria-label, tipo de elemento e maxlength, e depois pegamos a melhor pontuação acima de um limiar. Um textarea é o sinal isolado mais forte e ainda assim não é decisivo: muitos formulários usam textarea para endereço, e alguns usam um input de linha única com maxlength de 500 para a mensagem. Assunto e mensagem são o par que mais dá errado. Os dois são texto, ficam um do lado do outro, e trocá-los dá ao destinatário um assunto de quatro parágrafos.

Depois há os campos que precisam ser deixados em paz. Um honeypot é um input escondido de uma pessoa e deixado visível para um script ingênuo, e preenchê-lo é exatamente como o site aprende que você é um script. Marcamos um input como honeypot em qualquer um destes casos: escondido por display ou visibility, posicionado fora da tela, opacidade zero, tabindex negativo, aria-hidden, ou um nome do conjunto de sempre (url, website, hp, _gotcha, ou comment quando já existe um campo de comentário). Um input marcado é enviado exatamente como o encontramos. Isso não é contornar nada. É preencher o formulário do jeito que o navegador na frente de uma pessoa preencheria.

Abaixo do limiar não chutamos. Não há porta, a leitura estaciona com o motivo escrito, e ela aparece no registro de recusas como recusada em vez de como tentativa silenciosa. Postar um parágrafo no campo rotulado "Como você ficou sabendo da gente" é pior que não postar nada, porque gasta a atenção de um estranho dentro de um formulário ao qual ele não pode responder.

Um detector que não consegue reportar "não sei" sempre vai achar uma porta, inclusive nos sites que não têm nenhuma.

03Dois bugs que ficaram quietos exatamente do jeito errado

O primeiro é a pior coisa que já subimos. O detector devolve um resultado aninhado e o achado do CAPTCHA fica em result.form.captcha. A trava lia result.captcha. Essa propriedade é undefined, undefined é falsy, e a trava concluiu portanto que nenhum formulário em lugar nenhum tinha CAPTCHA. Nada lançou erro. Nada logou. Formulários protegidos passaram por quase uma tarde inteira, e isso só apareceu porque uma checagem manual contra um formulário que sabíamos ser protegido voltou limpa e ninguém acreditou no resultado.

A correção é uma regra, e não um caminho de propriedade corrigido. A trava agora exige um false explícito para prosseguir. Qualquer outra coisa estaciona o job: undefined, null, um objeto ausente, uma string, um valor truthy. Afirmamos o formato do resultado do detector na fronteira em vez de ler através dele com otimismo, e existe um teste que entrega à trava um resultado com a flag movida um nível mais fundo e espera um estacionamento. Ele falha contra o código antigo, que é a única definição útil de teste de regressão.

MétodoTravas falham fechando. Uma checagem de segurança que lê um valor ausente precisa tratar a ausência como o caso perigoso, porque há dois jeitos de obter um valor ausente: um site sem CAPTCHA, e um detector que você quebrou. Só um dos dois é seguro, e a checagem não consegue distinguir os dois.

O segundo bug é menor e custou mais horas. O casamento de campos permitia casar por sufixo, então um input chamado contact[email] e um input chamado contact[secondary_email] satisfaziam ambos uma regra que procurava um campo de e-mail, e o vencedor era o que viesse por último na ordem do documento. Em um formulário em que o endereço secundário era opcional e sem validação, mapeamos um campo obrigatório sobre o opcional. O post deu certo, o site devolveu uma página de agradecimento, e a mensagem caiu em um campo que ninguém lê.

Três regras resolvem isso agora, aplicadas em ordem. Um casamento exato de nome vence um casamento por sufixo e encerra a busca. Obrigatório vence opcional, lido do atributo required, do aria-required, e da definição de campo da plataforma quando a plataforma fornece uma. Empates vão para a posição anterior no documento. O mapeamento resultante é escrito no registro de auditoria, então a pessoa que aprova o rascunho consegue ver em qual input cada valor vai cair antes de aprovar, e não depois.

04Onde paramos

Nunca resolvemos nem contornamos um CAPTCHA. Nem com um serviço de solver, nem com um navegador ajustado para parecer mais humano, nem caçando o endpoint legado desprotegido que fica atrás do protegido, que é o mesmo ato com modos melhores. Quando o detector reporta um CAPTCHA, o job vai para needs_review e fica ali até uma pessoa abrir. Na maior parte das vezes ninguém abre, e isso é um resultado em vez de uma falha.

Um CAPTCHA em um formulário de contato é uma preferência declarada. O dono disse, em quase o único vocabulário disponível para dizer isso a um software, que quer uma pessoa do outro lado. Estamos no ramo de pedir a estranhos que considerem comprar alguma coisa, e essa conversa não abre bem demonstrando que as preferências deles não nos vinculam.

Algumas outras coisas não se qualificam como porta. Um formulário atrás de login não é público. Um formulário de ticket de suporte é o público errado, porque é uma fila mantida para clientes pagantes e uma mensagem de vendas ali custa a tarde de alguém. Um cadastro de newsletter não é rota de contato. Cada um desses é registrado como leitura recusada com motivo escrito, o mesmo tratamento que uma empresa recebe ao reprovar em um filtro de encaixe.

A entrega hoje passa pelo formulário de contato público da empresa alvo e por mais nada. O envio de e-mail está planejado para o quarto trimestre de 2026 e não está construído, então não há rota alternativa quando falta uma porta e não há segundo canal quando o primeiro está protegido. Essa ausência é o que mantém a regra honesta. Se existisse uma alternativa fácil, estacionar o job não custaria nada, e uma regra que não custa nada não diz nada.

Nada disso é engenhoso. É um detector de plataforma, uma função de pontuação, duas travas que falham fechando, e um motivo escrito anexado a toda leitura sobre a qual não agimos. A versão engenhosa deste sistema reportaria uma contagem maior de envios realizados e seria pior.

A Knockwire lê a internet, descarta as empresas que nunca vão comprar de você e bate na porta das que podem comprar. Rode no seu próprio domínio e leia as suas recusas.

Rodar no meu siteTodos os textos