Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format follows [Keep a Changelog] (https://keepachangelog.com/en/1.1.0/).

### Added

- **Aba Configurações → Captcha** (#1053): escolha entre os três modos, com o efeito de cada um dito na própria opção — o modo só-ALTCHA é recusado no salvamento sem HTTPS, porque o widget se recusa a rodar fora de contexto seguro e o formulário ficaria intransponível. Fator de trabalho e validade do desafio têm piso e teto; abaixo do piso a prova não custa nada, acima do teto um celular lento mói até o widget desistir aos 90s. Aviso dispensável sugerindo o modo mais forte só onde ele é possível.
- **Captcha ALTCHA, proof-of-work, servido inteiramente pelo próprio site** (#1053): o desafio sai de um endpoint do plugin (`ffc_altcha_challenge`), o trabalho acontece no navegador do visitante e a verificação é PHP puro — nenhuma requisição sai do servidor. Widget MIT vendorizado em `libs/js/`, sem CDN. Três modos: só matemático (padrão, inalterado no upgrade), só ALTCHA, e ALTCHA com o matemático em `<noscript>`. A tela de configuração chega na sequência; até lá o padrão é o único valor em jogo.

### Changed
Expand Down
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,33 @@ Every plugin CSV export flows through **one source contract with two delivery ad

**When auditing, grep every legacy output site** (`php://output`, `fopen(`, `header( 'Content-Type: text/csv`, `CsvStreamer`, `->stream(`) and confirm each export routes through a source + one of the two adapters — the #772 consolidation retired seven bespoke exporters this way. **One deliberate exception survives the grep:** `Frontend\PublicCsvExporter::stream_form_csv()` still writes to `php://output` directly. It is the graceful-degradation half of the only *public/frontend* export — a plain `<form>` POST to `admin-post.php` that works with JS disabled (admin exports need no such fallback; they run in wp-admin where JS is assured). It is **not** a `SyncSourceInterface` because it is a direct `admin_post` streaming handler (not an AJAX job) and it emits an HTML 413 page over the row cap, which does not fit the `rows(): iterable` contract. Leave it bespoke — do not "fix" it to satisfy the grep.

**The no-JS half is now conditional, and that is deliberate (#1053).** Under the ALTCHA-only captcha mode this path exists but cannot be completed: the widget needs JavaScript, so a visitor without it has no way to pass the challenge and `handle_request()` refuses. That is an accepted consequence rather than a regression — the audience for this export is operators working on a desktop, so the mode is defensible for them — but it does mean "works with JS disabled" is a property of the *math* and *composite* modes only. Keep the handler: it is the whole no-JS path in the two modes that have one, and it is also the rollback that needs no redeploy.

### Captcha architecture (one contract, three modes — #1053)

Every public form is guarded by the same block: a honeypot (provider-independent, owned by `Core\SecurityService`) plus a challenge from whichever strategy is configured. **Never verify or render a challenge directly** — go through the contract.

**The contract** is `Core\Captcha\CaptchaProviderInterface`: `id()` · `render_fields()` · `verify()` · `peek()` · `challenge_payload()`. `Core\Captcha\CaptchaProvider::resolve()` picks the strategy from `ffc_settings['captcha_provider']` and falls back to math on an unknown value — a typo in an option must not take the public forms down. Three strategies: `MathCaptcha` (`math`), `AltchaCaptcha` (`altcha`), `CompositeCaptcha` (`both`, ALTCHA with the math half inside `<noscript>`).

**`verify()` spends the challenge; `peek()` checks it without spending.** This distinction is the contract's, not one strategy's, and it exists because of a live bug: the public CSV download is the plugin's only two-request flow and validated the same token twice, so single-use tokens made the second request reject the answer the visitor had just been told was correct (#1061). The rule: **the challenge is consumed by the action it authorises, not by the metadata read that precedes it.** A `peek()` that merely skips the ledger is wrong — it must refuse an already-spent proof too, or the contradiction moves one request downstream. Any new strategy implements both.

**Composite mode is accessibility, not security.** The server accepts either proof, so an attacker picks the cheaper one and the effective strength equals the math challenge's. Say so wherever it is offered; do not let it be read as "both, therefore stronger".

**Five sites issue a challenge, and all five go through the provider:** the render sites (`SecurityService::render_security_fields()`), the four retry paths (`SecurityService::with_fresh_challenge()`), and the cached-page fragment refresh (`Frontend\DynamicFragments`). The last one was missed once and is the reason this is written down — its client half dispatches on the payload's `provider` key and leaves an unrecognised one alone rather than half-applying it.

**ALTCHA specifics that are not guessable and cost time to rediscover** (all verified against the vendored 3.2.2 bundle, not documentation):

- **The element accepts exactly nine attributes** — `auto`, `challenge`, `configuration`, `display`, `language`, `name`, `theme`, `type`, `workers`. Everything else (`hideLogo`, `hideFooter`, `humanInteractionSignature`, `setCookie`, the floating options) travels as JSON in `configuration`. **Written as an attribute it is ignored in silence** — no console error, no visible failure. `Core\Captcha\CaptchaSettings` is the one place that knows which is which.
- **Translations are not an attribute either.** They live in the `globalThis.$altcha.i18n` store, keyed by language and selected by `language`. `assets/js/ffc-captcha.js` registers the plugin's own strings there, which is what keeps the upstream 52 KB i18n bundle out of the page.
- **`maxnumber` does not exist in 3.x** — the word is absent from the bundle. The solver counts up without a ceiling until it reproduces the hash, so **difficulty is the size of the server's secret number and nothing else** (expected work ≈ half of it). It is still emitted in the challenge because the published format carries it and other clients read it.
- **The widget refuses to run outside a secure context** (`isSecureContext`), throwing rather than degrading. The ALTCHA-only mode is therefore blocked at save time without HTTPS; the composite mode is allowed because its `<noscript>` half still works.
- **Vendor the UMD build** (`libs/js/altcha-<version>.umd.js`). The ESM build needs `<script type="module">`, which only `wp_enqueue_script_module()` emits — WP 6.5+, above this plugin's declared floor of 6.4.
- The wire format is ALTCHA's original ("v1") challenge. Given a top-level `challenge` key the 3.x widget marks it `_version: 1` and posts back `{algorithm, challenge, number, salt, signature, took}` in base64, with the counter hashed **as a decimal string**. The expiry rides in the salt as `?expires=<unix>`; the salt is not signed directly and does not need to be, because `challenge` is the hash of salt plus secret.

**Keys, bounds and privacy defaults live in `CaptchaSettings`** — allowed values for each attribute, and the two bounded numbers. The work factor is clamped **on read as well as on save**, so a value written before a bound moved still lands somewhere the widget can cope with. `humanInteractionSignature` (pointer and keyboard timings) and `setCookie` are **forced off and deliberately not configurable**: these are public-sector forms under the LGPD, the proof of work already carries the anti-automation load, and an administrator toggling them back on would change what the site has to disclose without being told so.

**Signing and single use are shared, not per-strategy:** `Core\Captcha\ChallengeSigner` (key derived from `wp_salt('nonce')` — no option, nothing for `uninstall.php` or the fresh-install manifest) and `Core\Captcha\ChallengeStore` (transient ledger; `redeem()` spends, `is_spent()` reads). A new strategy reuses both rather than inventing its own.

---

## 4. Domain conventions
Expand Down
28 changes: 28 additions & 0 deletions assets/css/ffc-common.css
Original file line number Diff line number Diff line change
Expand Up @@ -690,3 +690,31 @@
transition: opacity 0.15s ease;
}

/* ── Captcha settings: mode chooser (#1053) ─────────────────────────
Each mode carries a consequence rather than a preference, so the note
sits with its option instead of in a paragraph above the group. */
.ffc-captcha-mode {
margin: 0 0 18px;
max-width: 46em;
}

.ffc-captcha-mode-summary,
.ffc-captcha-mode-note {
display: block;
/* Indented under the radio so the note reads as belonging to its option. */
margin-left: 24px;
}

.ffc-captcha-mode-note {
margin-top: 4px;
padding-left: 10px;
border-left: 3px solid transparent;
}

.ffc-captcha-mode-note--warning {
border-left-color: #dba617;
}

.ffc-captcha-mode-note--info {
border-left-color: #72aee6;
}
2 changes: 1 addition & 1 deletion assets/css/ffc-common.min.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions includes/admin/class-ffc-admin-activity-log-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ public static function get_action_label( string $action ): string {
'pdf_generated' => __( 'PDF Generated', 'ffcertificate' ),
'certificate_emailed' => __( 'Certificate Emailed', 'ffcertificate' ),
'csv_downloaded' => __( 'CSV Downloaded', 'ffcertificate' ),
'captcha_fallback_used' => __( 'Captcha: fallback used', 'ffcertificate' ),

// Submission lifecycle.
'submission_trashed' => __( 'Submission Trashed', 'ffcertificate' ),
Expand Down
1 change: 1 addition & 0 deletions includes/admin/class-ffc-admin-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public function init(): void {
RoleCapabilityEditor::init();
AdminMenuVisibility::init();
DeviceThresholdUpgradeNotice::init();
CaptchaModeNotice::init();
EncryptionKeyHealthNotice::init();
HtmlRefsNotice::init();
SettingsAjaxEndpoint::init();
Expand Down
107 changes: 107 additions & 0 deletions includes/admin/class-ffc-captcha-mode-notice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php
/**
* Dismissible nudge toward the ALTCHA-only captcha mode (#1053).
*
* The math challenge has a 46-value answer space; a script that does
* arithmetic passes it. Signed, expiring, single-use tokens close replay, but
* they do not make the question harder — so on a site that can run the widget,
* staying on the math challenge is leaving the weakest option in place.
*
* Three gates, and each has a reason:
*
* - HTTPS only. The widget refuses to run outside a secure context, so on a
* plain-HTTP site this would be advice the administrator cannot take.
* - Not already on ALTCHA-only. Nothing to suggest.
* - Dismissible, one-shot. A site on the composite mode may have chosen
* reach deliberately; the suggestion is worth making once, not every
* time they open the admin.
*
* @package FreeFormCertificate\Admin
* @since 6.23.0
*/

declare(strict_types=1);

namespace FreeFormCertificate\Admin;

use FreeFormCertificate\Core\Captcha\AltchaCaptcha;
use FreeFormCertificate\Core\Captcha\CaptchaProvider;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Suggests the ALTCHA-only mode on sites that can run it.
*/
class CaptchaModeNotice extends AbstractDismissibleNotice {

const OPTION_DISMISSED = 'ffc_captcha_mode_notice_dismissed';
const NONCE_ACTION = 'ffc_dismiss_captcha_mode';
const AJAX_ACTION = 'ffc_dismiss_captcha_mode';

/**
* Option key the dismissed flag is stored under.
*/
protected static function option_key(): string {
return self::OPTION_DISMISSED;
}

/**
* Nonce + `wp_ajax_{action}` hook suffix.
*/
protected static function action(): string {
return self::AJAX_ACTION;
}

/**
* Stable class for styling / test hooks.
*/
protected static function extra_class(): string {
return 'ffc-captcha-mode-notice';
}

/**
* One-shot: a plain flag, so once dismissed it stays dismissed.
*/
protected static function dismiss_signature(): string {
return '1';
}

/**
* Only where the advice can actually be taken, and is not already taken.
*/
protected static function should_show(): bool {
if ( ! class_exists( '\FreeFormCertificate\Core\Captcha\CaptchaProvider' ) ) {
return false;
}

// Advice an administrator cannot act on is noise: without a secure
// context the widget throws rather than degrading.
if ( ! is_ssl() ) {
return false;
}

return AltchaCaptcha::ID !== CaptchaProvider::resolve()->id();
}

/**
* The inner notice HTML (one paragraph, already escaped).
*/
protected static function notice_message(): string {
$captcha_url = admin_url( 'admin.php?page=ffc-settings&tab=captcha' );

return '<p><strong>'
. esc_html__( 'Free Form Certificate — captcha', 'ffcertificate' )
. '</strong> — '
. wp_kses(
sprintf(
/* translators: %s: link to the captcha settings tab */
__( 'This site is served over HTTPS, so it can run the ALTCHA proof-of-work challenge — a stronger guard than the arithmetic question, and still served entirely from this site with nothing sent to a third party. Consider switching in %s. Keep the fallback mode if some of your visitors browse without JavaScript.', 'ffcertificate' ),
'<a href="' . esc_url( $captcha_url ) . '">' . esc_html__( 'Settings → Captcha', 'ffcertificate' ) . '</a>'
),
array( 'a' => array( 'href' => array() ) )
)
. '</p>';
}
}
78 changes: 78 additions & 0 deletions includes/admin/class-ffc-settings-save-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

namespace FreeFormCertificate\Admin;

use FreeFormCertificate\Core\Captcha\AltchaCaptcha;
use FreeFormCertificate\Core\Captcha\CaptchaProvider;
use FreeFormCertificate\Core\Captcha\CaptchaSettings;
use FreeFormCertificate\Core\Captcha\MathCaptcha;
use FreeFormCertificate\Submissions\SubmissionHandler;

if ( ! defined( 'ABSPATH' ) ) {
Expand Down Expand Up @@ -92,6 +96,7 @@ private function save_general_and_specific_settings(): void {
$clean = $this->save_qrcode_settings( $clean, $new );
$clean = $this->save_date_format_settings( $clean, $new );
$clean = $this->save_url_shortener_settings( $clean, $new );
$clean = $this->save_captcha_settings( $clean, $new );

// "Email Model" chrome lives in its own option (`ffc_email_template`),
// posted from its own form in the SMTP tab.
Expand Down Expand Up @@ -401,6 +406,79 @@ private function save_date_format_settings( array $clean, array $new ): array {
return $clean;
}

/**
* Save the captcha strategy and its widget options (#1053).
*
* Gated on the tab marker, like every other section here, so saving any
* other tab preserves these keys rather than rebuilding them from a POST
* that never carried them.
*
* @param array<string, mixed> $clean Current settings.
* @param array<string, mixed> $new New settings from POST.
* @return array<string, mixed> Updated settings.
*/
private function save_captcha_settings( array $clean, array $new ): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in handle_all_submissions().
$ffc_tab = isset( $_POST['_ffc_tab'] ) ? sanitize_key( wp_unslash( $_POST['_ffc_tab'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing

if ( 'captcha' !== $ffc_tab ) {
return $clean;
}

$requested = CaptchaSettings::one_of(
$new['captcha_provider'] ?? '',
CaptchaProvider::available(),
MathCaptcha::ID
);

/*
* The ALTCHA widget refuses to run outside a secure context — it
* throws "Secure context (HTTPS) required" rather than degrading — so
* a mode with no arithmetic fallback would leave every visitor on a
* plain-HTTP site with a form they cannot submit. Refuse the save and
* keep whatever was configured before, rather than accepting a value
* that produces a broken public page.
*
* The composite mode is allowed: its `<noscript>` half still works,
* which is exactly what it is for.
*/
if ( AltchaCaptcha::ID === $requested && ! is_ssl() ) {
add_settings_error(
'ffc_settings',
'ffc_captcha_requires_https',
__( 'The ALTCHA-only mode was not saved: the widget refuses to run on a page that is not served over HTTPS, which would leave every visitor unable to submit. Serve the site over HTTPS, or choose the mode that keeps the math challenge as a fallback.', 'ffcertificate' ),
'error'
);

$requested = CaptchaSettings::one_of(
$clean['captcha_provider'] ?? '',
CaptchaProvider::available(),
MathCaptcha::ID
);
}

$clean['captcha_provider'] = $requested;

if ( isset( $new['captcha_altcha_complexity'] ) ) {
$clean['captcha_altcha_complexity'] = CaptchaSettings::clamp_complexity( $new['captcha_altcha_complexity'] );
}
if ( isset( $new['captcha_altcha_ttl'] ) ) {
$clean['captcha_altcha_ttl'] = CaptchaSettings::clamp_ttl( $new['captcha_altcha_ttl'] );
}

$clean['captcha_altcha_type'] = CaptchaSettings::one_of( $new['captcha_altcha_type'] ?? '', CaptchaSettings::types(), 'checkbox' );
$clean['captcha_altcha_auto'] = CaptchaSettings::one_of( $new['captcha_altcha_auto'] ?? '', CaptchaSettings::auto_modes(), 'off' );
$clean['captcha_altcha_display'] = CaptchaSettings::one_of( $new['captcha_altcha_display'] ?? '', CaptchaSettings::displays(), 'standard' );
$clean['captcha_altcha_theme'] = CaptchaSettings::one_of( $new['captcha_altcha_theme'] ?? '', CaptchaSettings::themes(), '' );

// Checkboxes: absent from the POST means unchecked.
$clean['captcha_altcha_hide_logo'] = empty( $new['captcha_altcha_hide_logo'] ) ? 0 : 1;
$clean['captcha_altcha_hide_footer'] = empty( $new['captcha_altcha_hide_footer'] ) ? 0 : 1;

return $clean;
}

/**
* Save URL Shortener settings (v5.1.0)
*
Expand Down
17 changes: 17 additions & 0 deletions includes/admin/class-ffc-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ public function load_tabs(): void {
'email_texts' => '\\FreeFormCertificate\\Settings\\Tabs\\TabEmailTexts',
'cache' => '\\FreeFormCertificate\\Settings\\Tabs\\TabCache',
'url_shortener' => '\\FreeFormCertificate\\Settings\\Tabs\\TabUrlShortener',
'captcha' => '\\FreeFormCertificate\\Settings\\Tabs\\TabCaptcha',
'rate_limit' => '\\FreeFormCertificate\\Settings\\Tabs\\TabRateLimit',
'geolocation' => '\\FreeFormCertificate\\Settings\\Tabs\\TabGeolocation',
'ip_diagnostics' => '\\FreeFormCertificate\\Settings\\Tabs\\TabIpDiagnostics',
Expand Down Expand Up @@ -315,6 +316,22 @@ public function get_default_settings(): array {
'url_cleanup_trashed' => 1, // Short-URL cleanup: status = 'trashed'.
'public_access_disable_days' => 90, // Grace window (days) for disabling Public Operator Access on old forms.
'code_editor_theme' => 'dark', // 'dark' | 'light' | 'auto' (auto follows dark_mode).
// Captcha (#1053). `math` on upgrade and on a fresh install: the
// arithmetic challenge is the only one that runs without
// JavaScript and without a secure context, so it is the choice
// that cannot lock anyone out of a form they could use before.
'captcha_provider' => 'math', // 'math' | 'altcha' | 'both'.
// Work factor for the ALTCHA proof of work — the upper bound of
// the secret number the solver has to find, so expected work is
// about half of it. Bounded by CaptchaSettings, not here.
'captcha_altcha_complexity' => 200000,
'captcha_altcha_ttl' => 600, // Seconds a challenge stays valid.
'captcha_altcha_type' => 'checkbox', // 'checkbox' | 'switch'.
'captcha_altcha_auto' => 'off', // 'off' | 'onfocus' | 'onload' | 'onsubmit'.
'captcha_altcha_display' => 'standard', // 'standard' | 'bar' | 'floating'.
'captcha_altcha_theme' => '', // '' follows the visitor's system preference.
'captcha_altcha_hide_logo' => 0,
'captcha_altcha_hide_footer' => 0,
);
}

Expand Down
Loading
Loading