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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
/node_modules/
/build/
/.phpunit.result.cache
/.wp-env.override.json
.DS_Store
14 changes: 14 additions & 0 deletions .wp-env.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://schemas.wp.org/trunk/wp-env.json",
"phpVersion": "8.2",
"plugins": [
".",
"../gravityforms"
],
"config": {
"WP_TESTS_DOMAIN": "localhost",
"WP_TESTS_EMAIL": "test@genero.fi",
"WP_TESTS_TITLE": "ALTCHA for Gravity Forms Tests",
"WP_PHP_BINARY": "php"
}
}
132 changes: 131 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,46 @@ the `gravityformsaddon_gravityforms-altcha_settings` option, the per-form one in
the form meta). The `genero/gravityforms_altcha/should_protect` filter can still
override the saved settings programmatically.

### Protection strength

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
can override the site-wide strength or inherit it. For an exact custom value,
use the `genero/gravityforms_altcha/cost` filter.

### Extra spam layers (optional)

Two independent, fully first-party layers can be toggled on under Forms →
Settings → **ALTCHA**. Both **mark suspicious submissions as spam** (via
`gform_entry_is_spam`) rather than rejecting them — a real visitor is never
blocked or shown an error, and a false positive stays recoverable in the entry
*Spam* view. They apply to every Gravity Form, independent of whether ALTCHA
itself is enabled.

* **Rate limiting** — flags submissions once an IP exceeds a per-form
allowance within a window (default 3 per hour). The IP is resolved independently of Gravity
Forms (sites often blank GF's stored IP for GDPR) and kept only as a salted
HMAC in a 60-second transient — the raw IP is never stored or logged. Behind
a CDN/proxy, point it at the right client-IP header (see
`genero/gravityforms_altcha/client_ip_headers`).
* **Content spam filtering** — flags submissions whose text contains a
definite-spam keyword (matched on word boundaries), or accumulates enough
weaker signals (link farms, a URL in the name field, injected markup,
wrong-script text). Scans all visitor text including composite name/address
fields, and ignores zero-width characters used to evade matching.
* **Email validation** — unlike the two above, this *blocks* the field (with a
corrective message) so the visitor fixes a bad address rather than silently
never hearing back. Per-verdict checkboxes decide what to reject —
**undeliverable** (default on), **risky** (default off — may catch some real
catch-all/role addresses), and **disposable** (default on). Verifies via
[Bouncer](https://usebouncer.com); requires the `BOUNCER_API_KEY` environment
variable. **Fails open** — `unknown`, a missing key, or an API error never
block. Note: this sends the submitted email to a third-party service, so
cover it in your privacy policy / DPA.

## How it works

1. **Form render** — when enabled for the form, the plugin injects a hidden
Expand All @@ -61,6 +101,10 @@ override the saved settings programmatically.
4. **Server-side verification** — `gform_validation` decodes the payload,
reconstructs the challenge, and runs `altcha-org/altcha::verifySolution()`.
On failure the submission is rejected with a generic error message.
5. **Replay protection** — each challenge is single-use. A solved payload's
unique signature is remembered for the rest of its lifetime, so the same
proof can't be replayed across many submissions — a bot must solve a fresh
challenge every time rather than paying the cost once.

Day-to-day configuration lives in the admin UI (see [Settings](#settings)); the
filters below cover advanced overrides.
Expand Down Expand Up @@ -102,16 +146,102 @@ Localise or rewrite the validation error:
add_filter('genero/gravityforms_altcha/error_message', fn () => __('Spam check failed. Please reload and try again.', 'your-textdomain'));
```

### `genero/gravityforms_altcha/cost`

Set an exact proof-of-work cost (PBKDF2 iterations), overriding the
*Protection strength* dropdown. Receives the form id for context:

```php
add_filter('genero/gravityforms_altcha/cost', fn (int $cost, ?int $formId) => 750000, 10, 2);
```

### `genero/gravityforms_altcha/client_ip_headers`

Only relevant when *Rate limiting* is on. Ordered list of `$_SERVER` keys to
read the client IP from; defaults to `['REMOTE_ADDR']`. Behind a CDN, **prepend
the single header your CDN sets** — never trust a forwarded header it doesn't,
as it can be spoofed to evade the limit or push a real visitor over it.

```php
add_filter('genero/gravityforms_altcha/client_ip_headers', fn () => [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'REMOTE_ADDR',
]);
```

Common headers: `HTTP_CF_CONNECTING_IP` (Cloudflare), `HTTP_FASTLY_CLIENT_IP`
(Fastly), `HTTP_TRUE_CLIENT_IP` (Akamai), `HTTP_X_REAL_IP` (nginx).

### `genero/gravityforms_altcha/rate_limit_max` and `…/rate_limit_window`

Per-IP, per-form submission allowance and the window (seconds) it applies over
before a submission is flagged as spam. Default: 3 per hour. For "1 per minute":

```php
add_filter('genero/gravityforms_altcha/rate_limit_max', fn () => 1);
add_filter('genero/gravityforms_altcha/rate_limit_window', fn () => MINUTE_IN_SECONDS);
```

### `genero/gravityforms_altcha/spam_keywords` and `…/spam_score_threshold`

Tune the content filter — definite-spam keywords (a single match flags) and the
score the weaker heuristics must reach (each signal contributes 2; default 3):

```php
add_filter('genero/gravityforms_altcha/spam_keywords', fn (array $words) => [...$words, 'crypto']);
add_filter('genero/gravityforms_altcha/spam_score_threshold', fn () => 4);
```

### `genero/gravityforms_altcha/bouncer_api_key`

Provide the Bouncer key in code instead of the `BOUNCER_API_KEY` env var (e.g.
from a secrets manager):

```php
add_filter('genero/gravityforms_altcha/bouncer_api_key', fn () => get_option('my_bouncer_key'));
```

### `genero/gravityforms_altcha/email_should_validate` and `…/email_error_message`

Skip email validation for specific fields/forms, or customise the rejection
message:

```php
add_filter('genero/gravityforms_altcha/email_should_validate', fn (bool $v, $field, $form) => (int) $form['id'] !== 12, 10, 3);
add_filter('genero/gravityforms_altcha/email_error_message', fn () => __('That email looks undeliverable — please check it.', 'your-textdomain'));
```

## Development

```bash
composer install
npm install
npm run build # outputs build/widget.js
composer test # PHPUnit suite, no WordPress dependency
composer lint:fix # Pint
```

## Tests

Two layers:

* **`composer test`** — the `unit` (pure logic) and `mocked` (WP functions stubbed
with Brain\Monkey) suites. No WordPress, no database — runs in CI on PHP
8.2–8.4.
* **`composer test:integration`** — real-WordPress integration tests
(`wp-phpunit`) covering the settings, the `gform_entry_is_spam` spam layers,
and cost resolution against an actual Gravity Forms install. Gravity Forms is
commercial, so these **skip when it isn't present** (e.g. CI) and run for real
via wp-env or DDEV.

Run the integration suite with [`wp-env`](https://www.npmjs.com/package/@wordpress/env)
(place a `gravityforms` checkout alongside this repo so it mounts):

```bash
npx @wordpress/env start
npx @wordpress/env run tests-cli --env-cwd=wp-content/plugins/gravityforms-altcha \
vendor/bin/phpunit -c phpunit.integration.xml.dist
```

## License

MIT — see [`LICENSE`](./LICENSE).
Expand Down
20 changes: 17 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
},
"require-dev": {
"laravel/pint": "^1.17",
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^9.0",
"brain/monkey": "^2.6",
"wp-phpunit/wp-phpunit": "^7.0",
"yoast/phpunit-polyfills": "^2.0"
},
"autoload": {
"psr-4": {
Expand All @@ -25,12 +28,23 @@
},
"autoload-dev": {
"psr-4": {
"Genero\\GravityFormsAltcha\\Tests\\": "test/unit/"
"Genero\\GravityFormsAltcha\\Tests\\": "test/unit/",
"Genero\\GravityFormsAltcha\\Tests\\Mocked\\": "test/mocked/",
"Genero\\GravityFormsAltcha\\Tests\\Integration\\": "test/integration/"
}
},
"scripts": {
"lint": "pint --test",
"lint:fix": "pint",
"test": "phpunit"
"test": "phpunit",
"test:integration": "phpunit -c phpunit.integration.xml.dist"
},
"config": {
"platform": {
"php": "8.2"
},
"allow-plugins": {
"composer/installers": true
}
}
}
Loading
Loading