feat(captcha): ALTCHA proof-of-work as a selectable strategy (#1053 PR3) - #1065
Merged
Conversation
UMD, not the ESM build the plan named. `wp_enqueue_script()` emits no `type="module"`; `wp_enqueue_script_module()` does, and it needs WP 6.5 while this plugin declares 6.4. The UMD build registers the same `altcha-widget` custom element, costs the same 113 KB, and matches how thumbmarkjs is already vendored here. Kept byte-identical to upstream — no licence banner prepended — so its sha256 still matches jsDelivr's published manifest (HQdoDu5mw7WRxmOGQPe/djESv4jXIjozH30rSfDOSWg=) and the vendored copy stays verifiable. The MIT licence sits beside it instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
Verification in the order that matters: signature before anything in the payload is believed, then the solution binding salt and number to the authenticated challenge, then the expiry read from that now-trusted salt, then the one-time ledger. `peek()` refuses a spent proof too — reporting it valid and failing only at the step that consumes it is the contradiction #1061 removed. Not yet wired: the challenge endpoint and the template it renders do not exist, so this class is unreachable and untested for now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
The endpoint exists because a challenge baked into the HTML goes stale — and
worse, is shared — behind a full-page cache. Three things it does on purpose:
`nocache_headers()`, because a cached challenge is one token the first solver
spends for everyone; a bare JSON body rather than the `{success, data}`
envelope, which the widget would not find its fields inside; and its own
per-address throttle instead of `RateLimiter::check_ip_limit()`, which records
an attempt in the submission tables and would spend a visitor's budget because
their browser loaded a widget. Fixed windows via a bucketed key, the idiom
`check_global_limit()` already uses, so a steady caller crosses into a new
bucket instead of holding one open.
Two corrections to what the plan assumed about the widget, both the same
mistake — treating an ALTCHA option as an HTML attribute. The 3.x element
accepts exactly nine: auto, challenge, configuration, display, language, name,
type, theme, workers. `hideLogo`, `hideFooter`, `humanInteractionSignature`
and `setCookie` are not among them and travel as JSON in `configuration`;
written as attributes they are ignored in silence. Translations are not an
attribute either: they live in the `globalThis.$altcha.i18n` store and are
selected by `language`, which is why the upstream 52 KB i18n bundle stays out
of the page.
`humanInteractionSignature` defaults to true and collects pointer and keyboard
timings. Turned off: these are public-sector forms under the LGPD, and the
proof of work already carries the anti-automation load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
Registers the two widget modes behind the existing contract — ALTCHA alone, and ALTCHA with the math challenge in <noscript> — enqueues the vendored bundle only for the modes that render one, and adds the glue the widget does not provide. `CompositeCaptcha` routes on which proof was actually posted rather than trying both: with two error messages in hand, reporting the wrong one is how a visitor gets told to redo arithmetic they never saw. It is accessibility, not security — the server accepts either proof, so an attacker takes the cheaper one and the effective strength is the math challenge's. Its docblock and the settings copy say so. The reset-on-rejection requirement is met with one wire rather than N call sites: `FFC.request` now dispatches `ffc:request-rejected` on every server-side refusal, and the captcha module listens. A solved challenge is spent the moment it reaches the server, whether or not the captcha is what failed, so a widget still showing "verified" after any rejection would send a token that can never verify again. Three test findings worth keeping. `AjaxWiringTest` caught a real orphan and, doing so, showed two blind spots of its own. Its literal-idiom regex backtracked out of the `nopriv_` prefix on `add_action( 'wp_ajax_nopriv_' . self::ACTION, … )`, capturing `nopriv_` as an action name — a handler nothing could ever call. An atomic group does not fix it, since the `?` may still discard the group whole; the quantifier has to be possessive. And an endpoint class holding its action in a constant is called through that constant, never the literal, so direction A now accepts `TheClass::THE_CONST` as a caller — otherwise every such handler reads as an orphan. Four test classes were passing only because `get_locale` had never been defined in the process: `LabelSorter::locale()` guards on `function_exists( 'get_locale' )`, so they were exercising the no-WordPress branch by accident of file ordering. Adding tests that stub it made the guard pass and the call land unmocked. They now pin the locale explicitly, to the same value the fallback produced. The JS suite first measured loads instead of behaviour: the module registers document-level listeners, and re-running it per test stacked them. It now reads its localised payload on use rather than at load, so it is loaded once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
rpgmem
marked this pull request as ready for review
September 5, 2026 18:05
rpgmem
enabled auto-merge (squash)
September 5, 2026 18:05
Coverage Report for CI Build 33982408725Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Warning No base build found for commit Coverage: 89.899%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
84 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR3 do épico #1053. Entrega o ALTCHA atrás do contrato de estratégia que a PR2 criou. Nada muda para instalações existentes: o padrão continua o desafio matemático, e a tela que permite trocar chega na PR4.
Summary
Core\Captcha\AltchaCaptcha— emite o desafio, verifica a solução, implementapeek(). ReusaChallengeSigner(chave derivada dewp_salt) eChallengeStore(consumo único), então não há option nova, nada emuninstall.phpe nada para o gate de fresh-install reconciliar.Core\Captcha\AltchaChallengeEndpoint—ffc_altcha_challenge, priv + nopriv, com throttle por endereço.Core\Captcha\CompositeCaptcha— modo A: widget mais o desafio matemático em<noscript>.assets/js/ffc-captcha.js— registra as traduções no store do widget, reseta em toda rejeição do servidor, e transforma um estado de erro em algo acionável.Correções ao plano do épico
O reconhecimento do bundle 3.2.2 contradisse quatro decisões travadas. Todas verificadas no código do widget, não em documentação:
altcha-3.2.2.min.js<script type="module">— emitido só porwp_enqueue_script_module(), WP 6.5+, e o plugin declara 6.4. Vendorizei o UMD: script clássico, mesmo custom element, mesmo tamanhohideLogo/hideFooter/humanInteractionSignaturecomo opções do widgetauto,challenge,configuration,display,language,name,theme,type,workers. Os demais viajam como JSON emconfiguration; escritos como atributo são ignorados em silênciocost × countermaxnumbernão existe no 3.x — a palavra não ocorre no bundle. O solver conta sem teto até reproduzir o hash, então a dificuldade é o tamanho do número secreto e nada mais. O guardrail da PR4 precisa ser reescrito sobre esse Nif (!isSecureContext) throw new Error("Secure context (HTTPS) required."). O widget falha, não degrada. O modo A deixa de ser conveniência e vira a única opção viável em HTTP puroDuas decisões confirmadas:
challenge="<url>"está certo (string iniciada em{é lida como JSON, o resto é buscado como URL), e o widget devolve o payload clássico{algorithm, challenge, number, salt, signature, took}quando o desafio vem no formato v1 — logo o verificador PHP no formato v1 é o correto.Eu mesmo caí na armadilha dos atributos ao escrever
strings="…"no elemento. Não existe: em 3.x as traduções vivem emglobalThis.$altcha.i18n, selecionadas porlanguage. Teria aparecido em inglês, sem erro nenhum no console. Corrigido, e o template carrega a nota para o próximo leitor.Decisões de implementação
O endpoint responde JSON cru, não
{success, data}. O widget lê o corpo como desafio ALTCHA e não acharia os campos dentro de um envelope.nocache_headers()é obrigatório. Um desafio cacheado é um desafio compartilhado, e com consumo único o primeiro a resolver gasta o de todo mundo.Throttle próprio, não
RateLimiter::check_ip_limit(). Aquele grava tentativa nas tabelasffc_rate_limit_*, que contam envios — gastar a cota de alguém porque o navegador carregou um widget estrangularia quem não preencheu um campo sequer. Balde temporal fixo, idioma quecheck_global_limit()já usa, IP hasheado na chave.Reset em toda rejeição, com um fio só.
FFC.requestpassa a emitirffc:request-rejectedem qualquer recusa do servidor. Uma solução é gasta assim que chega ao servidor, seja ou não o captcha o que falhou, então um widget ainda mostrando "verificado" mandaria um token que nunca mais verifica. Ligar isso em cada ponto de rejeição exigiria achar todos — e todos os futuros.Ordem da verificação. Assinatura primeiro (autenticidade, antes de acreditar em qualquer byte do payload), depois a solução (que amarra salt e número ao desafio autenticado), só então a expiração — porque até a solução bater, o salt que a carrega não é sabidamente nosso.
Type of change
Test plan
composer test— 7561 testes / 21740 asserções, verdecomposer lint(PHPStan) — nível 8 limpo nos arquivos novos e tocadosphpcs-tests.xml.dist, ESLint e Stylelint limposffc_settings['captcha_provider']à mãoOs testes PHP resolvem o próprio desafio, contando até reproduzir o hash, exatamente como o widget faz — sem browser. Um teste que montasse o payload a partir dos internos da classe passaria mesmo se o formato divergisse do que o widget posta. Cobrem: formato do fio, solução válida, uso único,
peekque não gasta,peekque recusa gasto, assinatura forjada, número errado, expirado, salt sem expiração, e nove payloads malformados (oarrayonde se esperastringchega aohash()comoTypeErrorsobstrict_types— a classe do #1058).Três achados nos próprios testes
A
AjaxWiringTestpegou um órfão real e, ao fazê-lo, mostrou dois pontos cegos seus. O regex do idioma literal retrocedia para fora do prefixonopriv_emadd_action( 'wp_ajax_nopriv_' . self::ACTION, … )e capturavanopriv_como nome de ação — um handler que nada poderia chamar. Grupo atômico não resolve, porque o?ainda pode descartar o grupo inteiro; o quantificador tem de ser possessivo. E uma classe-endpoint que guarda a ação numa constante é chamada por essa constante, nunca pelo literal, então a direção A passa a aceitarClasse::CONSTANTEcomo chamador — sem isso, todo handler desse tipo lê como órfão.Quatro classes de teste passavam por acidente de ordem de arquivos.
LabelSorter::locale()tem guardafunction_exists( 'get_locale' ), e elas exercitavam o ramo "sem WordPress" apenas porque nada no processo havia definido a função. Meus testes a definem; a guarda passou e a chamada não-mockada estourou em 20 testes. Agora fixam o locale explicitamente, no mesmo valor que o fallback produzia — dependência escolhida, não herdada.A suíte JS media cargas, não comportamento. O módulo registra listeners em
document, e recarregá-lo por teste os empilhava: a asserção de reset contava 5 em vez de 1. O módulo passou a ler o payload localizado no uso, não na carga, e o teste o carrega uma vez.Checklist
npm run build) and the resulting*.min.*files are committedCHANGELOG.mdupdated —[Unreleased] → Added; sem bump deFFC_VERSIONSobre o widget vendorizado: bytes idênticos ao upstream, sem banner de licença prefixado, então o
sha256confere contra o manifesto publicado do jsDelivr —HQdoDu5mw7WRxmOGQPe/djESv4jXIjozH30rSfDOSWg=. A licença MIT está emlibs/js/altcha-3.2.2.LICENSE.txt, ao lado.Nota sobre o PHPStan local: rodo o PHAR, que não carrega
szepeviktor/phpstan-wordpress, então ele reporta 3 erros em arquivos que esta PR não toca (csv-writer,url-shortener-cleaner,audience-admin-audience-renderer). São artefato do meu ambiente; a CI é a fonte de verdade.Ainda em aberto no épico
?page=ffc-settings&tab=captcha, seletor de modo,WP_Errorno modo C sem HTTPS, e o guardrail reescrito sobre o número secreto.🤖 Generated with Claude Code
https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
Generated by Claude Code