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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion gravityforms-altcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions resources/scripts/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void>}
*/
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,
});
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

class Plugin
{
public const VERSION = '0.6.1';
public const VERSION = '0.6.2';

public const SLUG = 'gravityforms-altcha';

Expand Down
15 changes: 11 additions & 4 deletions src/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array{label: string, value: string}>
*/
public static function costChoices(bool $includeInherit): array
Expand All @@ -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'],
]);
}

Expand Down
Loading