Skip to content

Épico: substituir o captcha matemático por ALTCHA self-hosted #1053

Description

@rpgmem

Contexto

O captcha original gerava hash = wp_hash( $answer . 'ffc_math_salt' ). O hash derivava só da resposta, com salt fixo, sem vínculo de sessão, expiração ou uso único. Um par (ffc_captcha_ans, ffc_captcha_hash) capturado uma vez valia para sempre, em qualquer formulário. O espaço de respostas era 0–45.

Estado: código todo entregue. PR1 (#1054), PR2 (#1055), PR3 (#1065) e PR4 (#1066), mais sete correções intercaladas (#1057, #1059, #1062, #1064, #1067, #1068, #1069). Os três modos foram smoke-testados e produziram dez achados, todos corrigidos. Falta apenas traduzir as strings novas (workflow do Loco, fora do código) e reconferir no ambiente de testes. A PR5 foi cortada — ver "Flip do padrão: parado, não planejado".

Decisões travadas

Tema Decisão
Biblioteca ALTCHA, implementação nativa em PHP; lib MIT oficial (altcha-org/altcha) como especificação de referência
Motivo vs. Cap Cap não tem servidor PHP de primeira parte; os portes comunitários trazem aviso explícito de não-monitoramento de segurança, e o plugin WP do Cap exige instância Cap externa
Widget Vendorizado em libs/js/, no padrão do thumbmarkjs. Sem CDN
Chave HMAC Derivada de wp_salt( 'nonce' ) — sem option nova, sem entrada em uninstall.php
Anti-replay Transient ffc_*, consumo one-time no verify
Modos A ALTCHA + fallback math · B só math · C só ALTCHA
Recomendado C — o público do download CSV são organizadores, em desktop com JS
Padrão B no upgrade e na instalação nova
Endpoint challenge="<url>" apontando para AJAX próprio — imune a cache de página
UI Aba nova ?page=ffc-settings&tab=captcha. Sem capability nova
Versão 6.23.0 minor, com Security + Added
humanInteractionSignature Desligado, decisão registrada no código e na doc
setCookie Mantido desligado
i18n Strings do plugin via $altcha.i18n + wp_localize_script, não o bundle de 52 KB
Argon2 / Scrypt Fora de escopo — exigem workers vendorizados à parte

Modo A é acessibilidade, não segurança. Como o servidor aceita qualquer uma das duas provas, o atacante escolhe a mais barata — a força efetiva do A é a do math. Fica no código como compatibilidade e rollback.

Nenhum servidor próprio. Emissão do desafio = admin-ajax.php do próprio plugin; proof-of-work = navegador do visitante; verificação = PHP do WordPress; tráfego externo = nenhum.

Conferir ≠ gastar (aprendizado do #1061, vale para o ALTCHA)

O uso único do token, introduzido na PR1, quebrou o único fluxo de duas requisições do plugin: o download público de CSV valida o mesmo captcha na tela de detalhes e no download. A tela 1 queimava o token e a tela 2 recusava a resposta que o visitante acabara de ver aceita.

A correção separou as duas operações, e isso é agora parte do contrato, não da implementação matemática:

  • CaptchaProviderInterface::peek() — confere sem resgatar. Toda estratégia que vale a pena é de uso único: uma solução de proof-of-work é replayável até o servidor registrá-la, exatamente como o token matemático. Implementá-lo como um verify() que não consome é errado — um token já gasto precisa ser recusado no peek também, senão a contradição só anda um passo adiante.
  • Regra: o captcha é consumido pela ação que ele autoriza, não pela leitura de metadados que a antecede.

PRs

✅ PR1 — Security: fechar o replay do captcha matemático — #1054 (mergeado)

  • Core\Captcha\ChallengeStore — emissão stateless, consumo one-time no verify
  • Hash math vinculado a expiração + consumo único
  • Reescrever tests/Unit/UtilsTest.php e tests/Unit/FrontendShortcodesTest.php
  • Teste de regressão: mesmo payload duas vezes → segunda rejeitada
  • Entrada em [Unreleased] / Security
  • Core\Captcha\ChallengeSigner — assinatura com chave derivada, separada do ledger (SRP)
  • Nonce por desafio — sem ele, dois visitantes com a mesma resposta no mesmo segundo compartilhariam um token
  • SecurityService::with_fresh_challenge() no catch único do pipeline e em 13 branches das quatro superfícies AJAX
  • Bloco ===== CAPTCHA DEBUG ===== removido do SecurityFieldsGuard

Regressão que escapou: o uso único quebrou o fluxo de duas requisições do CSV público. Corrigido no #1061. A revisão da PR1 mediu 13 branches de rejeição que precisavam de desafio novo e não perguntou quais superfícies validam o mesmo token mais de uma vez — nenhum teste cobria isso, porque cada handler é testado com o outro mockado.

✅ PR2 — contrato e unificação — #1055 (mergeado)

  • CaptchaProviderInterface + MathCaptcha + Core\Captcha\CaptchaProvider::resolve() (fallback silencioso para math em valor desconhecido)
  • SecurityService::validate_security_fields() delega; os 6 sites de verificação não mudaram — hoje são 5 que consomem + 1 que confere (ajax_info(), desde o Download público de CSV: captcha aceito na tela 1 e recusado na tela 2 (regressão do #1054) #1061)
  • Unificar as renderizações duplicadas — viraram templates/security-fields.php + templates/captcha/math-fields.php
  • .ffc-security-container na cadeia de seletores do assets/js/ffc-dynamic-fragments.js
  • CaptchaProviderTest
  • Correção de flake herdada: timer de 2s pendente em csv-download.test.js (cb5eb53)
  • Piso de cobertura — medido em 89,60% contra piso 86; folga dentro da tolerância de 5pp, então não foi mexido de propósito
  • @covers SecurityService no CaptchaProviderTest (entregue no fix(captcha): scope the booking refresh to its form and make the ids unique #1057)

Auditoria pós-merge — dois itens ficaram de fora, ambos fechados no #1064:

  • DynamicFragments::handle() não passava pelo contrato — chamava SecurityService::generate_simple_captcha() direto, em dois pontos. Era o quinto site de refresh; a PR2 cobriu os quatro de retry e não viu este, deixando o docblock de challenge_payload() afirmando um consumidor que ele não tinha.
  • Shortcodes::get_new_captcha_data() não tinha chamador — removido direto: diferente do precedente do get_audit_log_summary() (Deprecation cycle: get_audit_log_summary() legacy keys + UserDataRestController facade #730), não era public static alcançável por FQCN.

✅ Intercalado — #1056: ids duplicados e refresh não escopado — #1057 (mergeado)

  • Ids do captcha únicos por render
  • refreshCaptcha escopado ao formulário, casando por name
  • 4 testes JS + 3 PHP; 3 dos JS verificados falhando contra o código antigo
  • Varredura de timers longos — adiado com medição (16 timers inventariados, zero vazamento observável)

✅ Intercalado — #1058: agendamento commitado reportado como falha — #1059 (mergeado)

Encontrados pelo smoke manual, pré-existentes e sem relação com o captcha:

  • TypeError fatal pós-commit — $appointment['id'] chega do $wpdb como string e AppointmentReceiptHandler::get_receipt_url() declara int sob strict_types
  • catch do handler passou a \Throwable e o bloco pós-commit foi isolado por etapa
  • Violação de FK no activity log — on_appointment_created() passava o id do agendamento no 4º argumento, que é $user_id
  • Verificado em produção: agendamento criado, e-mail enviado, appointment_created no log

✅ Intercalado — #1061: CSV público recusava o captcha que acabara de aceitar — #1062 (mergeado)

  • ChallengeStore::is_spent() — metade só-leitura do ledger
  • SecurityService::peek_simple_captcha() / peek_security_fields()
  • CaptchaProviderInterface::peek() + MathCaptcha::peek() — no contrato, não na implementação
  • ajax_info() confere; authorize_start() e handle_request() consomem
  • Guarda no nível do handler, verificada falhando contra o código antigo
  • Smoke dos dois caminhos: com JS (2 requisições) e sem JS (1 requisição, download direto — a tela intermediária é afordância do cliente)

✅ Concluída a auditoria da PR2 — #1064 (mergeado)

  • DynamicFragments::handle() encaminha challenge_payload() verbatim e não nomeia mais os campos
  • O cliente despacha pelo campo provider e ignora o que não reconhece, em vez de aplicar meio payload
  • Escopo do cliente passou de documento inteiro para um bloco de segurança por vez
  • securityBlocks() também coleta markup em cache anterior ao invólucro .ffc-security-container
  • Shortcodes::get_new_captcha_data() removido
  • 6 provas JS verificadas falhando contra o script anterior; PHP passou a verificar contra CaptchaProvider mockado
  • Duas armadilhas de fixture corrigidas — a segunda fazia toda asserção de "limpa a resposta" passar vacuamente

✅ PR3 — ALTCHA — #1065 (mergeado)

  • Vendorizar widget + licença, constante FFC_ALTCHA_VERSION — bytes idênticos ao upstream, sha256 confere com o manifesto do jsDelivr
  • Core\Captcha\AltchaCaptcha — challenge, HMAC, expiresAt, verificação com hash_equals + consumo one-time
  • AltchaCaptcha::peek() — confere sem consumir e recusa token já gasto
  • Endpoint ffc_altcha_challenge (priv + nopriv, sem nonce por construção), com throttle próprio por endereço
  • Core\Captcha\CompositeCaptcha (modo A), math dentro de <noscript>
  • assets/js/ffc-captcha.js — reset em toda rejeição, via um fio só (ffc:request-rejected, emitido pelo FFC.request)
  • Ramo altcha no applyChallenge() — nomeado explicitamente, porque "nada a fazer" e "provider desconhecido" são respostas diferentes
  • Mensagem legível quando o widget falha, com o caso de contexto inseguro dito à parte por não ser transitório
  • Testes PHP resolvem o próprio desafio — sem browser; testes JS mockam o widget

Quatro decisões do épico foram contraditas pelo bundle 3.2.2, todas verificadas no código do widget:

Plano Realidade
Vendorizar o build .min.js É o ESM, que exige wp_enqueue_script_module() — WP 6.5+, e o plugin declara 6.4. Vendorizado o UMD
hideLogo / hideFooter / humanInteractionSignature como opções do widget O elemento aceita nove atributos; os demais vão como JSON em configuration e, escritos como atributo, são ignorados em silêncio
Guardrail sobre o produto cost × counter maxnumber não existe no 3.x. A dificuldade é o tamanho do número secreto e nada mais
Bloquear o modo C sem HTTPS por prudência Não é prudência: o widget lança Secure context (HTTPS) required. O modo A é a única opção viável em HTTP puro

Confirmados: challenge="<url>" está correto, e o widget devolve o payload clássico v1 — logo o verificador PHP em v1 é o certo.

Três achados nos próprios testes: a AjaxWiringTest pegou um órfão real e revelou dois pontos cegos seus (retrocesso para fora do prefixo nopriv_, e chamada por constante de classe não reconhecida); quatro classes de teste passavam por acidente de ordem de arquivos, dependendo de get_locale nunca ter sido definida; e a suíte JS media cargas em vez de comportamento, por empilhar listeners.

✅ PR4 — UI e configuração — #1066 (mergeado)

  • TabCaptcha + includes/settings/views/ffc-tab-captcha.php, em Segurança, sem capability nova
  • Seletor de modo com a consequência dita na própria opção, não num parágrafo acima do grupo
  • Save do modo C recusado sem HTTPS, preservando o valor anterior
  • Admin notice recomendando C — dispensável, só sob HTTPS, oculto se já em C
  • Opções: type, auto, hideLogo, hideFooter, fator de trabalho e validade do desafio — display e theme também foram expostos aqui e removidos no fix(captcha): drop two dials that could not work, and fix the widget language (#1053) #1068, por serem dials que o bundle não honra
  • Guardrail com piso e teto sobre o fator de trabalho — e aplicado na leitura também, não só no save
  • Plumbing das chaves — declaradas em get_default_settings(), sanitizadas por tab, lidas por um leitor tipado
  • Log do uso de fallback com IP hasheado e truncado, com rótulo traduzido no Activity Log
  • reference-security.php — dizia "basic math challenge"
  • CLAUDE.md — justificativa do PublicCsvExporter emendada, mais uma seção nova sobre a arquitetura de captcha

Três desvios do plano, todos deliberados:

  1. algorithm não foi exposto. Só SHA-256 vem no bundle; oferecer SHA-384/512 exigiria vendorizar workers à parte, o que o épico já colocou fora de escopo. Um seletor de um item só é ruído.
  2. O bloqueio do modo C usa add_settings_error(), não WP_Error. É o idioma que todo o resto do SettingsSaveHandler usa para retorno de salvamento; um WP_Error precisaria de plumbing próprio para chegar à tela e não chegaria mais claro.
  3. O leitor tipado é CaptchaSettings, não acessores em SettingsReader. As chaves de captcha têm limites, e limites precisam morar em algum lugar de qualquer forma; espalhar SettingsReader::get_int() deixaria o clamp do lado de fora, onde cada leitor teria de repeti-lo. É a forma que o Declarative settings registry: declare each ffc_settings key once #993 defende, construída onde já se pagava por ela.

✅ Smoke do modo C, parte 1 — widget invisível fora dos formulários — #1067 (mergeado)

Primeiro achado do smoke: o widget aparecia nos certificados e não aparecia no CSV público nem nos agendamentos.

  • O wp_enqueue_script estava dentro de um if ( $has_form || $has_verification ) no Loader — a página de CSV não casa nenhum dos dois, então o <altcha-widget> era renderizado sem o script que o define, e um custom element sem definição é uma caixa vazia
  • Corrigido com registrar no Loader, enfileirar no render (AltchaCaptcha::render_fields()) — shortcodes rodam em the_content, antes do wp_footer, e os dois scripts são de rodapé
  • É a mesma armadilha do Fix Audience Bookings "Export CSV" button doing nothing (#772 regression) #783 (handler de clique dobrado num init*() que dava early-return na página errada): enfileirar por tipo de página é uma aposta sobre onde o shortcode aparece

✅ Smoke do modo C, parte 2 — seis achados — #1068 (mergeado)

  • Ordem de carregamento do idioma. O bundle carrega antes do glue, e é ele quem cria a store $altcha.i18n — então cada widget já tinha resolvido o idioma quando as strings do plugin eram registradas. registerStrings() passou a chamar reapplyLanguage(), que invoca o configure({ language }) publicado por cada elemento. ⚠ Esta correção era necessária mas não era a causa do sintoma — ver "As strings novas não estão traduzidas" abaixo
  • display (bar/floating) sumia com o widget. No CSS do próprio bundle, bar é position: fixed; bottom: -100% e floating é display: none; left/top: -100%. São modos fora da tela por construção, para fluxos ancorados a um botão de submit. Opção removida
  • theme não mudava nada. O bundle não tem uma regra [theme=, :host(, prefers-color-scheme ou color-scheme sequer — o atributo existe e não é lido. Opção removida
  • Um teste novo (test_layout_and_theme_are_not_offered_as_attributes) impede que os dois voltem, e um caso de save prova que um POST antigo com as chaves removidas não as ressuscita
  • Tema de verdade, no lugar do dial falso: variáveis --altcha-* mapeadas nos tokens --ffc-* em ffc-frontend.css. ⚠ Correção de uma afirmação minha: não são "as nove variáveis que o bundle lê" — o bundle expõe ~45; nove foram mapeadas, as que têm token equivalente no plugin. As demais ficam no padrão
  • Widget centralizado, opções do ALTCHA em caixa dedicada escondida no modo math-only, ícone da aba trocado por 🤖
  • Rebuild do ffc-captcha.min.js — o fix de idioma foi commitado sem regerar o bundle e o gate "Verify minified assets" pegou. Sem isso a correção teria ido para produção sem efeito nenhum

🔍 Smoke dos modos A e B — #1069

Modo B (só math) passou limpo. Modo A produziu dois achados, e a documentação da aba nova entrou junto.

  • O modo A se contradizia sem JavaScript. O aviso "This form requires JavaScript to verify that you are not a robot" pertence ao modo C, onde é verdade. No composto era renderizado do mesmo jeito — em vermelho, logo acima do campo matemático que o visitante deveria responder. O aviso virou parâmetro do render (AltchaCaptcha::render_widget( bool )), porque é propriedade do modo, não do widget
  • O painel vazio do widget também some sem JavaScript, pela mesma razão: o custom element nunca é definido, então é uma caixa azul sem conteúdo. Uma folha de estilo não sabe dizer "scripting está desligado", então a regra viaja dentro do mesmo <noscript> que carrega a metade matemática
  • Caixa do widget estreita demais. O widget nasce com 320px e --altcha-max-width é aplicado como width no wrapper interno; nessa largura o logotipo, que o rodapé empurra para a direita com justify-content: flex-end, não tem para onde ir e cola no texto. Agora ocupa a largura do painel
  • Revisão da documentação (page=ffc-settings&tab=documentation) — a aba Captcha não tinha página. Entrou config-captcha.php: os três modos lado a lado com o que cada um exige e custa, as opções do widget, e o que o proof-of-work envia e não envia. Duas menções desatualizadas corrigidas: a página de verificação dizia "math captcha" especificamente, e a de cache não dizia que nos modos ALTCHA não há o que atualizar
  • 3 testes novos em CompositeCaptchaTest, verificados falhando contra o código anterior

⚠ As strings novas não estão traduzidas — e essa é a causa real do widget em inglês

O .po/.pot não contêm nenhuma das strings deste épico — nem as do widget (I am not a robot, Verifying…, Protected by ALTCHA), nem as da aba, nem o aviso do <noscript>. Todas passam por __() corretamente; o catálogo é que não tem as entradas, então __() devolve o inglês.

Consequência para o diagnóstico do #1068: a reapplyLanguage() corrigiu uma dependência de ordem real (o widget resolve o idioma antes de o glue registrar as strings, e não voltava a olhar), mas o sintoma que o smoke viu teria continuado igual, porque o que era registrado na store já era inglês. A saída de console bundle: object dizia exatamente isso e eu li como confirmação do caminho de entrega, não como um catálogo vazio.

Não é trabalho de código. O catálogo é mantido pelo Loco Translate (x-generator: Loco no cabeçalho do .po) e não há nenhuma ferramenta de i18n no package.json, no composer.json ou nos workflows — sincronizar o .pot e traduzir é o fluxo do wp-admin. Vale para todas as strings novas do épico, não só as do widget.

Flip do padrão: parado, não planejado (era a PR5)

A PR5 previa forçar o modo C como padrão numa release posterior, com banner ⚠. Cortada, e não por preguiça: a premissa era que C é estritamente melhor, e não é — ele exige HTTPS e JavaScript. Trocar o padrão mudaria o comportamento justamente das instalações que nunca abriram a aba, que são as menos propensas a perceber que um formulário parou de aceitar envios.

O que a PR4 entregou torna o flip desnecessário: a aba expõe a escolha com a consequência de cada modo dita ao lado dela, o save recusa C onde ele não funciona, e o aviso empurra para C exatamente onde ele é possível. O operador recebe uma escolha informada em vez de uma mudança silenciosa de comportamento — mesmo raciocínio que estacionou o flip de IP do cliente no #902.

Revisitar só com gatilho: evidência de que o aviso não está sendo lido (instalações em HTTPS seguindo em math depois de várias releases), ou uma decisão de exigir JavaScript no plugin como um todo. Não por especificação.

Próximos passos, nesta ordem

  1. Row shapes honestas em todas as classes que leem do $wpdb — fechar a cegueira do PHPStan a tipos vindos do banco #1060 — formas de linha honestas nos 32 consumidores de $wpdb sem shape, mais o guarda de baseline.
  2. Fragmentos em cache: dois blocos de segurança na mesma página podem receber o mesmo token #1063 — dois blocos de segurança na mesma página podem receber o mesmo token.

Critérios de conclusão

Smoke manual — treze defeitos encontrados

O smoke no ambiente de testes foi o instrumento mais produtivo deste épico: encontrou treze defeitos que a suíte não pegava, três deles pré-existentes e sem relação com o captcha.

# Defeito Origem Entregue em
#1058 Agendamento commitado reportado como falha (TypeError pós-commit) Pré-existente #1059
#1058 Linha de activity log perdida por violação de FK Pré-existente #1059
#1061 CSV público recusava o captcha que acabara de aceitar Regressão da PR1 #1062
C-1 Widget invisível no CSV público e nos agendamentos (enqueue por tipo de página) Regressão da PR3 #1067
C-2 Widget em inglês (ordem de carregamento + catálogo sem as strings) PR3 / tradução #1068 + Loco
C-3 display = bar/floating faz o widget sumir (fora da tela pelo bundle) Opção sem base, da PR4 #1068
C-4 theme não faz nada (o bundle não lê o atributo) Opção sem base, da PR4 #1068
C-5 Widget descentralizado na caixa do shortcode Cosmético, da PR3 #1068
C-6 Opções do ALTCHA visíveis no modo math-only Cosmético, da PR4 #1068
C-7 Ícone da aba duplicava o do Rate Limit Cosmético, da PR4 #1068
A-1 Sem JavaScript, o modo A dizia que o formulário exigia JavaScript — acima do campo que o dispensa Regressão da PR3 #1069
A-2 Sem JavaScript, o painel do widget ficava como caixa azul vazia Regressão da PR3 #1069
A-3 Caixa do widget estreita demais: o logotipo colava no texto Cosmético, da PR3 #1069

O padrão comum aos três primeiros: cada lado está correto isoladamente e quebra na costura — entre duas requisições, entre duas camadas — e a suíte não vê porque cada lado é testado com o outro mockado. A AjaxWiringTest já cobre "os dois lados existem e concordam no nome"; nenhum guarda cobre "os dois lados concordam sobre quem consome o token" nem "o tipo que sai do $wpdb cabe no parâmetro que o recebe" (este último é o #1060).

O padrão comum aos achados dos modos ALTCHA é outro: falha silenciosa de integração com um componente de terceiros. O idioma errado, o atributo ignorado, o widget fora da tela e o custom element sem definição não emitem erro algum — nem no console, nem no PHP. Nenhum teste unitário os alcança, porque todos mockam o widget; só um navegador de verdade vê. Foi por isso que quatro decisões do épico precisaram ser corrigidas contra o código do bundle, nunca contra a documentação.

E um terceiro, específico do modo A: os dois achados sem JavaScript existiam porque o modo A nunca foi olhado sem JavaScript. A metade <noscript> é a razão de o modo existir e a única que nenhum teste alcança — jsdom sempre tem "JavaScript".

Pendências

Aberto de propósito, com o motivo:

Item Estado
Traduzir as strings novas Único item do épico realmente pendente. Fluxo do Loco Translate no wp-admin: sincronizar o .pot e traduzir. Vale para todo o épico, não só o widget
#1060 — formas de linha honestas Fora do épico, próximo na fila
#1063 — dois blocos de segurança compartilhando um token Pré-existente, agendado para depois do #1060
Varredura de timers longos (#1056) Adiado com medição: 16 timers inventariados, zero vazamento observável
Flip do padrão Cortado — ver acima

Nada mais ficou em aberto. Os cinco sites que emitem desafio passam pelo contrato, os seis que verificam também, e não há chamada a generate_simple_captcha() fora do provider.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions