Skip to content

security: bind the math captcha to an expiry and spend it on redemption - #1054

Merged
rpgmem merged 1 commit into
developfrom
claude/altcha-captcha-integration-4vvuer
Sep 5, 2026
Merged

security: bind the math captcha to an expiry and spend it on redemption#1054
rpgmem merged 1 commit into
developfrom
claude/altcha-captcha-integration-4vvuer

Conversation

@rpgmem

@rpgmem rpgmem commented Sep 5, 2026

Copy link
Copy Markdown
Owner

PR1 do épico #1053. Independente de todo o resto: entrega valor mesmo que o ALTCHA nunca aconteça.

Summary

  • O captcha matemático era contornável por replay. O token era wp_hash( $answer . 'ffc_math_salt' ) — derivado só da resposta, com salt fixo, sem vínculo de sessão, sem expiração e nunca consumido. Um par (resposta, token) capturado uma vez autenticava qualquer envio posterior, em qualquer formulário, indefinidamente. Com 46 respostas possíveis, o par nem precisava ser capturado.
  • Três propriedades fecham isso: o token passa a ser <expires>.<nonce>.<signature>, assinado com chave derivada de wp_salt( 'nonce' ); a expiração está dentro do payload assinado e é verificada; e o resgate queima a prova num registro de transients. O nonce é o que impede dois visitantes que tiram a mesma resposta no mesmo segundo de compartilharem um token — sem ele, o primeiro a enviar gastaria o desafio do outro.
  • Consumo tem uma consequência: uma rejeição levantada depois do gate de segurança deixaria o visitante com uma prova que nunca mais verifica, e a próxima tentativa falharia no captcha em vez de falhar no que de fato o rejeitou. Todo caminho desses passa a devolver um desafio novo — um ponto no pipeline de submissão (seu único catch de SubmissionRejected) e treze branches nas quatro superfícies AJAX que gateiam no captcha. O caminho sem JS do CSV não precisa de nada: ele redireciona para um formulário que re-renderiza.

O token continua viajando no campo ffc_captcha_hash que já existe, então os quatro sites de renderização e os três scripts ficam intocados.

Duas decisões de projeto que evitam plumbing: a chave deriva de wp_salt() em vez de uma option (nada a criar na ativação, nada a declarar em uninstall.php, nada para o gate fresh-install reconciliar), e o registro usa transients, que a varredura _transient_ffc_% do uninstall.php já remove e que o gate de manifesto não enxerga.

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would alter existing behavior)
  • Refactor / chore (no functional change)
  • Documentation only

Não há mudança de contrato para o cliente: mesmo campo, mesmo fluxo de refresh.

Test plan

  • composer test passa localmente — 7486 testes, 21610 asserções, verde
  • composer lint (PHPStan) — não executado: phpstan/phpstan é dist-only e o proxy deste ambiente devolve 403 na API do GitHub, então o pacote não instala aqui. Fica para o CI.
  • vendor/bin/phpcs --standard=phpcs.xml.dist includes/ — limpo
  • vendor/bin/phpcs --standard=phpcs-tests.xml.dist tests/ — limpo
  • Smoke manual no WordPress — pendente, vale exercitar no ambiente de testes: envio de certificado, verificação por código, agendamento e download CSV público (com e sem JS)
  • Testes novos e atualizados

Novo tests/Unit/CaptchaChallengeTest.php cobre assinatura e resgate. O guarda de regressão que importa é UtilsTest::test_verify_captcha_rejects_a_replayed_token: o mesmo par verifica uma vez e é recusado na segunda. Somam-se rejeição de token expirado, de token malformado, do digest legado só-da-resposta, e a asserção de que a mesma resposta nunca produz o mesmo token duas vezes.

Onze arquivos de teste existentes precisaram aprender o novo mundo: wp_salt e um registro de transients em memória onde antes bastava wp_hash, e with_fresh_challenge nos alias mocks de SecurityService. Dois helpers que fabricavam o token à mão agora o emitem pelo próprio serviço.

Checklist

  • Nenhuma mudança de CSS/JS — nada a re-minificar
  • CHANGELOG.md atualizado sob [Unreleased] / Security
  • Nenhuma entrada nova no baseline do PHPStan
  • Sem segredos, tokens ou PII no diff

Notas para revisão

Sem bump de FFC_VERSION — PR de feature contra develop. O lote fecha em 6.23.0 (minor, Security + Added), conforme o épico.

Piso de cobertura não foi ratcheted. O código novo tem boa cobertura medida isoladamente (ChallengeSigner 71%, ChallengeStore 80%, SecurityService 85% só com os dois testes filtrados; mais alto com a suíte inteira), mas a medição global completa não terminou a tempo neste ambiente. O piso segue em 86 — nunca baixado. Vale conferir o número que o CI reportar e ratchear se tiver folga.

Uma limitação assumida, documentada no código: o WordPress não oferece set-if-absent atômico para transients, então duas requisições que apresentem a mesma prova no mesmo instante podem ambas passar. A janela é de milissegundos e o prêmio é um envio extra que os rate limits por IP e por documento ainda limitam. A alternativa atômica seria um registro via add_option(), que escreveria options ffc_* que o gate fresh-install reportaria como não declaradas.

Refs #1053

🤖 Generated with Claude Code

https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU


Generated by Claude Code

The challenge token was `wp_hash( $answer . 'ffc_math_salt' )` — derived from
the answer alone, with a fixed salt. It carried no session binding, no expiry,
and was never consumed, so one captured `(answer, token)` pair authenticated
every later submission on any form, indefinitely. With 46 possible answers the
pair did not even need capturing.

Three properties close it. The token is now `<expires>.<nonce>.<signature>`,
signed with a key derived from `wp_salt( 'nonce' )`; the expiry is inside the
signed payload and checked on verify; and redemption burns the proof through a
transient ledger. The nonce is what keeps two visitors who draw the same answer
in the same second from sharing a token — without it the first to submit would
spend the other's challenge.

The token still travels in the existing `ffc_captcha_hash` field, so the four
render sites and three scripts are untouched.

Spending a challenge has a consequence: a rejection raised *after* the security
gate leaves the client holding a proof that will never verify again, so the
visitor's next attempt would fail on the captcha rather than on whatever
actually rejected them. Every such path now returns a fresh challenge —
one place in the submission pipeline (its single `SubmissionRejected` catch)
and thirteen branches across the four AJAX surfaces that gate on the captcha.
The no-JS CSV path needs nothing: it redirects to a form that re-renders.

Key derivation uses `wp_salt()` rather than a stored option deliberately:
nothing to create on activation, nothing to declare in `uninstall.php`, and
nothing for the fresh-install manifest gate to reconcile. Ledger entries are
transients, which the `_transient_ffc_%` sweep in `uninstall.php` already
removes and which the manifest gate does not see.

Refs #1053

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
@rpgmem
rpgmem force-pushed the claude/altcha-captcha-integration-4vvuer branch from e8c1b01 to 9b851a1 Compare September 5, 2026 01:43
@rpgmem
rpgmem marked this pull request as ready for review September 5, 2026 01:49
@rpgmem
rpgmem enabled auto-merge (squash) September 5, 2026 01:49
@rpgmem
rpgmem merged commit 0fb1278 into develop Sep 5, 2026
19 checks passed
@rpgmem
rpgmem deleted the claude/altcha-captcha-integration-4vvuer branch September 5, 2026 01:57
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 33937014748

Warning

No base build found for commit 0ef0b88 on develop.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 89.955%

Details

  • Patch coverage: 2 uncovered changes across 2 files (87 of 89 lines covered, 97.75%).

Uncovered Changes

File Changed Covered %
includes/core/captcha/class-ffc-challenge-signer.php 10 9 90.0%
includes/core/captcha/class-ffc-challenge-store.php 12 11 91.67%
Total (9 files) 89 87 97.75%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 53457
Covered Lines: 48087
Line Coverage: 89.95%
Coverage Strength: 4.81 hits per line

💛 - Coveralls

rpgmem added a commit that referenced this pull request Sep 5, 2026
…1061) (#1062)

The public CSV download is a two-request flow that validated the same
captcha twice: the info screen (`PublicCsvDownload::ajax_info()`) and the
download itself (`PublicFormsExportSource::authorize_start()`), which
re-posts the payload the info screen serialised.

That was free while tokens were replayable. Since #1054 they are single
use, so the info screen burned the token and the download rejected the
answer the visitor had just been told was correct.

Separate checking from spending, so the challenge is consumed by the
action it authorises rather than by the metadata read that precedes it:

- ChallengeStore::is_spent() — the read-only half of the ledger.
- SecurityService::peek_simple_captcha() / peek_security_fields() — check
  without redeeming. An already-spent token is refused here too, or the
  contradiction would merely move one request downstream.
- CaptchaProviderInterface::peek() — in the contract, not one strategy:
  a proof-of-work solution is replayable until the server records it,
  exactly as the math token is, so ALTCHA will need this as well.

`ajax_info()` now peeks. `authorize_start()` and the no-JS
`handle_request()` are single-request paths and keep consuming, which
preserves what #1054 closed: a captured (answer, token) pair is still
worth exactly one download.


Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU

Co-authored-by: Claude <noreply@anthropic.com>
rpgmem added a commit that referenced this pull request Sep 6, 2026
#1080)

O refresh de fragmentos emitia um desafio por FORMULÁRIO, mas quem consome um
desafio é o BLOCO DE SEGURANÇA — e os blocos de [ffc_self_scheduling] e
[ffc_csv_download] não vivem dentro de um `.ffc-form-wrapper`. Numa página que
misture os shortcodes, `count($form_ids) === 1`, nenhum mapa por formulário era
emitido, e os dois blocos caíam no mesmo payload padrão. Como o token é de uso
único desde o #1054, quem enviasse primeiro queimava o do outro: o segundo
formulário recusava uma resposta correta.

O cliente passa a enviar `blocks=<n>` (de `securityBlocks()`, que já existia) e
o servidor emite `n` desafios como lista; o cliente atribui um por bloco, em
ordem. Isso cobre os três casos com uma mecânica só — dois formulários,
formulário + bloco fora de wrapper, e dois blocos fora de wrapper, que a issue
não menciona e tinha o mesmo defeito.

O mapa `captchas[form_id]` foi substituído, não complementado. A consequência é
uma janela curta em que HTML em cache carrega o JS antigo contra o PHP novo: o
JS antigo procura `captchas['7']`, não acha e usa o padrão — ou seja, volta ao
comportamento de hoje até o cache girar. Manter os dois formatos seria duas
mecânicas para o mesmo trabalho.

Dois achados no caminho:

- O `formId` do laço de geofence nunca foi declarado — vivia do `var formId` do
  bloco de captcha reescrito aqui. Com `'use strict'` no IIFE, removê-lo sem
  declarar quebraria o refresh de geofence inteiro com ReferenceError.
- O endpoint é público e sem nonce por construção, e `form_ids` não tinha teto:
  cada id custa um `get_post_meta()`. Pré-existente, mas na exata linha
  reescrita aqui. Teto de 20 nas duas listas.

Refs #1063, #1054, #1056


Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants