diff --git a/README.md b/README.md index 5ae404e..2493f24 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,12 @@ override the saved settings programmatically. Forms → Settings → **ALTCHA** has a *Protection strength* dropdown (Low / Standard / High / Very high) controlling how hard the background proof-of-work is — stronger costs bots more per submission but takes longer to solve on -low-end devices. Because the widget solves on page load while the form is being -filled, even the higher settings are normally invisible. Each form's ALTCHA tab +low-end devices. The widget solves on page load while the form is being filled, +and a submit made before the proof is ready waits for it. The widget gives up +after 90 seconds (then retries with a fresh challenge), which *Very high* +regularly exceeds on ordinary laptops — prefer *Standard* unless a form is under +real attack. +Each form's ALTCHA tab can override the site-wide strength or inherit it. For an exact custom value, use the `genero/gravityforms_altcha/cost` filter. diff --git a/gravityforms-altcha.php b/gravityforms-altcha.php index 1adc94f..8d44322 100644 --- a/gravityforms-altcha.php +++ b/gravityforms-altcha.php @@ -6,7 +6,7 @@ * Plugin Name: ALTCHA for Gravity Forms * Plugin URI: https://github.com/generoi/gravityforms-altcha * Description: Invisible ALTCHA spam protection for Gravity Forms — uses the MIT-licensed altcha-org/altcha PHP library and the ALTCHA widget web component to proof-of-work-verify every form submission with no user interaction. - * Version: 0.6.1 + * Version: 0.6.2 * Requires at least: 6.0 * Requires PHP: 8.2 * Author: Genero diff --git a/resources/scripts/widget.js b/resources/scripts/widget.js index 9334b4b..bcd554a 100644 --- a/resources/scripts/widget.js +++ b/resources/scripts/widget.js @@ -21,6 +21,18 @@ import 'altcha'; const BOUND_FLAG = 'gfAltchaRenew'; +/** + * How many times a failed solve is retried with a fresh challenge before the + * widget is left in its error state. The widget gives up on a proof after its + * 90s timeout ("Failed to find solution.") — on a slow machine or a throttled + * background tab that's just bad luck with one challenge, and the next one + * usually lands. The cap stops a persistent failure (challenge endpoint down) + * from looping forever. + */ +const MAX_RETRIES = 3; + +const retries = new WeakMap(); + /** * Fetches a brand new challenge and solves it. `verify()` re-reads the * `challenge` attribute (a URL), so this issues a fresh challenge rather than @@ -61,6 +73,88 @@ function bind(widget) { // The widget dispatches `expired` on itself without `bubbles`, so the // listener has to sit on each element rather than on the document. widget.addEventListener('expired', () => renew(widget)); + + widget.addEventListener('statechange', (event) => { + const state = event.detail?.state; + if (state === 'verified') { + retries.delete(widget); + } else if (state === 'error' && canRetry(widget)) { + retries.set(widget, (retries.get(widget) ?? 0) + 1); + renew(widget); + } + }); +} + +/** + * @param {HTMLElement} widget + */ +function canRetry(widget) { + return (retries.get(widget) ?? 0) < MAX_RETRIES; +} + +/** + * Resolves once the widget holds a solved proof, or has failed for good. + * + * `auto="onload"` only *starts* the proof on load; nothing stops the visitor + * submitting before it's done, and the form then posts an empty `altcha` field + * that the server rejects as `missing`. On a rejected AJAX submit Gravity Forms + * re-renders the form, the replacement widget starts from zero, and a quick + * retry fails the same way — so the visitor never gets through. + * + * @param {HTMLElement} widget + * @returns {Promise} + */ +function whenSolved(widget) { + return new Promise((resolve) => { + const check = () => { + const state = widget.getState?.(); + if ( + state === undefined || + state === 'verified' || + (state === 'error' && !canRetry(widget)) || + !widget.isConnected + ) { + widget.removeEventListener('statechange', check); + resolve(); + } else if (state === 'expired' || state === 'unverified') { + renew(widget); + } + }; + widget.addEventListener('statechange', check); + check(); + }); +} + +/** + * Holds a Gravity Forms submission until the proof is ready. GF awaits async + * `gform/submission/pre_submission` filters before posting, and keeps its + * spinner up meanwhile, so the visitor just sees a slower submit instead of a + * rejection. If the proof fails for good the submission goes ahead and the + * server shows its "reload and try again" message. + */ +function holdSubmissions() { + const addAsyncFilter = window.gform?.utils?.addAsyncFilter; + if (!addAsyncFilter) { + return false; + } + + addAsyncFilter('gform/submission/pre_submission', async (data) => { + const widget = data.form?.querySelector('altcha-widget'); + if (widget) { + await whenSolved(widget); + } + + return data; + }); + + return true; +} + +// GF's own scripts normally load first, but don't depend on the order. +if (!holdSubmissions()) { + document.addEventListener('DOMContentLoaded', holdSubmissions, { + once: true, + }); } /** diff --git a/src/Plugin.php b/src/Plugin.php index 8099fbe..4150bdc 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,7 +4,7 @@ class Plugin { - public const VERSION = '0.6.1'; + public const VERSION = '0.6.2'; public const SLUG = 'gravityforms-altcha'; diff --git a/src/Settings.php b/src/Settings.php index e34c2e4..a44d1c0 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -167,6 +167,13 @@ public function form_settings_fields($form) * raw numbers. Power users can still set any exact value via the * `genero/gravityforms_altcha/cost` filter. * + * A solve takes ~256 attempts on average (the default key prefix is one + * zero byte), each costing `cost` iterations. Browser WebCrypto PBKDF2 runs + * ~1M iterations/s per core even on a fast machine, so the times assume + * ~10M/s for a fast desktop and ~2.5M/s for a typical office laptop (old + * phones and throttled tabs are slower still). The widget gives up after + * 90s, so "Very high" fails outright on ordinary hardware. + * * @return array */ public static function costChoices(bool $includeInherit): array @@ -178,10 +185,10 @@ public static function costChoices(bool $includeInherit): array } return array_merge($choices, [ - ['label' => esc_html__('Low — lightest, weakest deterrent (~1s)', 'gravityforms-altcha'), 'value' => '50000'], - ['label' => esc_html__('Standard — recommended balance (~4s)', 'gravityforms-altcha'), 'value' => (string) Challenge::DEFAULT_COST], - ['label' => esc_html__('High — stronger deterrent (~10s)', 'gravityforms-altcha'), 'value' => '500000'], - ['label' => esc_html__('Very high — strongest, may briefly delay submit on old devices (~20s)', 'gravityforms-altcha'), 'value' => '1000000'], + ['label' => esc_html__('Low — lightest, weakest deterrent (~1–5s)', 'gravityforms-altcha'), 'value' => '50000'], + ['label' => esc_html__('Standard — recommended balance (~5–20s)', 'gravityforms-altcha'), 'value' => (string) Challenge::DEFAULT_COST], + ['label' => esc_html__('High — stronger deterrent, slow to submit on older devices (~15–50s)', 'gravityforms-altcha'), 'value' => '500000'], + ['label' => esc_html__('Very high — not recommended, often fails on ordinary laptops and phones (~25–100s+)', 'gravityforms-altcha'), 'value' => '1000000'], ]); }