Skip to content

fix(captcha): stop the CSV info screen from spending the challenge (#1061) - #1062

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

fix(captcha): stop the CSV info screen from spending the challenge (#1061)#1062
rpgmem merged 1 commit into
developfrom
claude/altcha-captcha-integration-4vvuer

Conversation

@rpgmem

@rpgmem rpgmem commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes #1061.

Summary

  • O download público de CSV é um fluxo de duas requisições que validava o mesmo captcha duas vezes: a tela de detalhes (PublicCsvDownload::ajax_info()) e o download (PublicFormsExportSource::authorize_start()), que re-posta o payload serializado pela tela de detalhes. Isso saía de graça enquanto o token era replayável; desde o security: bind the math captcha to an expiry and spend it on redemption #1054 ele é de uso único, então a tela 1 o queimava e a tela 2 recusava a resposta que o visitante acabara de ver aceita.
  • A correção separa conferir de gastar: o captcha passa a ser consumido pela ação que ele autoriza, não pela leitura de metadados que a antecede. ajax_info() confere; authorize_start() e o caminho sem JS handle_request() consomem.
  • peek() entra no contrato (CaptchaProviderInterface), não numa estratégia: uma solução de proof-of-work é replayável até o servidor registrá-la, exatamente como o token matemático, então o ALTCHA vai precisar disso igualmente.

A propriedade fechada pelo #1054 permanece intacta: um par (resposta, token) capturado continua valendo um download — o mesmo que vale para um visitante legítimo.

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

Test plan

  • composer test passes locally — suíte completa, 7525 testes / 21680 asserções, verde
  • composer lint (PHPStan) passes locally — nível 8, sem erros nos arquivos tocados; WPCS limpo (regra principal e phpcs-tests.xml.dist)
  • Manual smoke test in WordPress admin / frontend — foi o smoke test do site de testes que encontrou o bug; a validação em produção do fix fica com o mantenedor antes do ready
  • Updated or added unit tests where relevant

Cobertura nova, em duas camadas:

UnidadeChallengeStore::is_spent() (não escreve no ledger; um proof conferido duas vezes ainda pode ser resgatado), MathCaptcha::peek() (aceita resposta correta, recusa errada, deixa o desafio gastável, recusa um já gasto) e SecurityService::peek_security_fields() (mantém o gate do honeypot; não consome, enquanto validate_security_fields() continua consumindo).

Handlertest_ajax_info_peeks_at_the_captcha_instead_of_spending_it prova que ajax_info() chama peek_security_fields() e nunca validate_security_fields(). Verifiquei que essa guarda falha quando o código regride (revertendo a chamada, o teste passa a receber json_error).

Checklist

  • Source CSS/JS changes were re-minified (npm run build) and the resulting *.min.* files are committed — n/a, a correção é só PHP
  • CHANGELOG.md updated (and readme.txt == Changelog == section if this is a release-bound change) — entrada em [Unreleased] → Fixed; sem bump de FFC_VERSION, que pertence à release
  • No new PHPStan baseline entries unless explicitly justified
  • No secrets, tokens, or personally identifiable information in the diff

Nota de revisão

Vale olhar com atenção o includes/frontend/class-ffc-public-csv-download.php: handle_request() e ajax_info() têm blocos de captcha quase idênticos, e um primeiro patch meu trocou o errado — o que teria feito o caminho sem JS conferir sem nunca gastar o token, reabrindo o replay exatamente onde ele importa. Os comentários em cada bloco agora dizem qual é qual e por quê.

🤖 Generated with Claude Code

https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU


Generated by Claude Code

…1061)

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
@rpgmem
rpgmem marked this pull request as ready for review September 5, 2026 15:27
@rpgmem
rpgmem enabled auto-merge (squash) September 5, 2026 15:27
@rpgmem
rpgmem merged commit 3845329 into develop Sep 5, 2026
19 checks passed
@rpgmem
rpgmem deleted the claude/altcha-captcha-integration-4vvuer branch September 5, 2026 15:40
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 33974768944

Warning

No base build found for commit 1b53da2 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.923%

Details

  • Patch coverage: 37 of 37 lines across 4 files are fully covered (100%).

Uncovered Changes

No uncovered changes found.

Coverage Regressions

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


Coverage Stats

Coverage Status
Relevant Lines: 53536
Covered Lines: 48141
Line Coverage: 89.92%
Coverage Strength: 4.84 hits per line

💛 - Coveralls

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