From 20621da75e5b0deb15d9d60953ae67bf7cd7626b Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 1 Aug 2026 16:20:33 +0100 Subject: [PATCH 1/6] feat: Add optional human-verification challenge to form submissions Public form endpoints could only be defended with a honeypot and a minimum submit time, both of which cost an attacker nothing once they bother to look. Submissions can now additionally require a solved human-verification challenge, with Cloudflare Turnstile as the first provider and other providers pluggable behind a contract. The challenge is off unless configured, so existing installations keep their current behaviour and make no outbound call. A provider that cannot be reached answers 503 rather than 422: the submitter is probably human, and an outage at the provider should read as a retry rather than as an accusation. The verified token is discarded instead of being kept with the submission. --- CHANGELOG.md | 6 + README.md | 36 ++++- config/eloquent-forms.php | 20 +++ src/Challenges/ChallengeManager.php | 40 ++++++ src/Challenges/TurnstileVerifier.php | 63 ++++++++ src/Contracts/ChallengeVerifier.php | 18 +++ .../ChallengeUnavailableException.php | 28 ++++ src/FormsServiceProvider.php | 6 + .../Controllers/FormSubmissionController.php | 6 +- src/Http/Requests/SubmitFormRequest.php | 28 ++++ tests/Feature/ChallengeVerificationTest.php | 135 ++++++++++++++++++ 11 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 src/Challenges/ChallengeManager.php create mode 100644 src/Challenges/TurnstileVerifier.php create mode 100644 src/Contracts/ChallengeVerifier.php create mode 100644 src/Exceptions/ChallengeUnavailableException.php create mode 100644 tests/Feature/ChallengeVerificationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2965dff..d42e5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] +- Optional human-verification challenge on public submissions, off unless configured +- Cloudflare Turnstile ships as the first challenge provider; others plug in behind a challenge contract +- A challenge provider that cannot be reached answers 503 rather than rejecting the submitter +- The challenge token is verified and discarded instead of being stored with the submission + ## [0.1.0] - 2026-07-23 - Polymorphic form collection: a Form definition and its submissions, each scoped to any owning model via an owner morph - Public, throttled submission endpoint that resolves a form by key and records the submission diff --git a/README.md b/README.md index 5603cef..38dd76d 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,10 @@ submission is stored, then delivered to any number of configured destinations (mail, webhook, Mautic, SmartPings, ...). Add a destination once and every form gains it. -## Install (in this monorepo) - -The package is developed under `api/packages/eloquent-forms` and wired as a path -repository in the app's `composer.json`. From `api/`: +## Install ```bash -composer install # symlinks the package and discovers the provider +composer require whilesmart/eloquent-forms php artisan migrate # creates forms + form_submissions php artisan vendor:publish --tag=eloquent-forms-config # optional ``` @@ -30,8 +27,33 @@ columns, everything else is retained in `payload`. Include two protection fields the frontend should send: -- `_gotcha` — honeypot, must be empty (configurable name) -- `_started_at` — unix-ms timestamp of when the form rendered (time-trap) +- `_gotcha`: honeypot, must be empty (configurable name) +- `_started_at`: unix-ms timestamp of when the form rendered (time-trap) + +## Challenges + +The honeypot and the time trap cost a bot nothing to defeat once someone +bothers. Set `FORMS_CHALLENGE` to require a solved human-verification challenge +as well: + +```env +FORMS_CHALLENGE=turnstile +TURNSTILE_SECRET_KEY=your-turnstile-secret-here +``` + +The frontend renders the widget with its own public site key and posts the +resulting token as `cf_turnstile_response`. The token is verified server-side, +then dropped rather than stored with the submission. + +A rejected token fails validation like any other field (422). A provider that +cannot be reached answers 503 instead, so an outage at the provider reads as a +retry rather than as an accusation. + +Leaving `FORMS_CHALLENGE` unset runs no challenge and makes no outbound call. + +Add another provider by implementing +`Whilesmart\Forms\Contracts\ChallengeVerifier` and registering it in +`config('eloquent-forms.challenge_drivers')`. ## Destinations diff --git a/config/eloquent-forms.php b/config/eloquent-forms.php index a534bc0..58b87f1 100644 --- a/config/eloquent-forms.php +++ b/config/eloquent-forms.php @@ -34,6 +34,26 @@ 'rate_limit_per_minute' => env('FORMS_RATE_LIMIT', 10), // Max characters accepted for any single freeform value. 'max_value_length' => 5000, + // Human-verification challenge, resolved through `challenge_drivers`. + // Null runs no challenge; the honeypot and time trap still apply. + 'challenge' => env('FORMS_CHALLENGE'), + ], + + /* + | Challenge driver map. Each key resolves to a class implementing + | Whilesmart\Forms\Contracts\ChallengeVerifier. Add your own provider here + | and every form gains it. + */ + 'challenge_drivers' => [ + 'turnstile' => \Whilesmart\Forms\Challenges\TurnstileVerifier::class, + ], + + 'turnstile' => [ + // Only the secret belongs here. The site key is public and is baked + // into the frontend bundle that renders the widget. + 'secret' => env('TURNSTILE_SECRET_KEY'), + 'token_field' => env('TURNSTILE_TOKEN_FIELD', 'cf_turnstile_response'), + 'timeout' => env('TURNSTILE_TIMEOUT', 5), ], /* diff --git a/src/Challenges/ChallengeManager.php b/src/Challenges/ChallengeManager.php new file mode 100644 index 0000000..9f48a2c --- /dev/null +++ b/src/Challenges/ChallengeManager.php @@ -0,0 +1,40 @@ +container->make($map[$key]); + + if (! $instance instanceof ChallengeVerifier) { + throw new InvalidArgumentException("Form challenge [{$key}] must implement ChallengeVerifier."); + } + + return $instance; + } +} diff --git a/src/Challenges/TurnstileVerifier.php b/src/Challenges/TurnstileVerifier.php new file mode 100644 index 0000000..b65888e --- /dev/null +++ b/src/Challenges/TurnstileVerifier.php @@ -0,0 +1,63 @@ +timeout((int) config('eloquent-forms.turnstile.timeout', 5)) + ->post(self::VERIFY_URL, array_filter([ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $ipAddress, + ])); + } catch (Throwable $e) { + throw new ChallengeUnavailableException('Turnstile could not be reached.', 0, $e); + } + + if ($response->failed()) { + throw new ChallengeUnavailableException( + sprintf('Turnstile answered %d.', $response->status()) + ); + } + + if ($response->json('success') === true) { + return true; + } + + Log::info('Turnstile rejected a submission.', [ + 'errors' => $response->json('error-codes', []), + 'ip' => $ipAddress, + ]); + + return false; + } +} diff --git a/src/Contracts/ChallengeVerifier.php b/src/Contracts/ChallengeVerifier.php new file mode 100644 index 0000000..58192ef --- /dev/null +++ b/src/Contracts/ChallengeVerifier.php @@ -0,0 +1,18 @@ +expectsJson()) { + return null; + } + + return app(ResponseFormatterInterface::class)->failure( + 'Verification is unavailable. Please try again shortly.', + 503, + ); + } +} diff --git a/src/FormsServiceProvider.php b/src/FormsServiceProvider.php index 872885c..206b2b5 100644 --- a/src/FormsServiceProvider.php +++ b/src/FormsServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Whilesmart\Forms\Challenges\ChallengeManager; use Whilesmart\Forms\Destinations\DestinationManager; use Whilesmart\Forms\Interfaces\ResponseFormatterInterface; @@ -29,6 +30,11 @@ public function register(): void DestinationManager::class, fn ($app) => new DestinationManager($app) ); + + $this->app->singleton( + ChallengeManager::class, + fn ($app) => new ChallengeManager($app) + ); } public function boot(): void diff --git a/src/Http/Controllers/FormSubmissionController.php b/src/Http/Controllers/FormSubmissionController.php index 19e388d..455dcd2 100644 --- a/src/Http/Controllers/FormSubmissionController.php +++ b/src/Http/Controllers/FormSubmissionController.php @@ -4,6 +4,7 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Str; +use Whilesmart\Forms\Challenges\ChallengeManager; use Whilesmart\Forms\Events\FormSubmittedEvent; use Whilesmart\Forms\Http\Requests\SubmitFormRequest; use Whilesmart\Forms\Jobs\ProcessFormSubmission; @@ -30,7 +31,10 @@ public function store(SubmitFormRequest $request, string $key) return $this->failure('Origin not allowed.', 403); } - $payload = $request->except(['_started_at']); + $payload = $request->except(array_filter([ + '_started_at', + app(ChallengeManager::class)->verifier()?->tokenField(), + ])); $submission = new FormSubmission([ 'form_id' => $form->id, diff --git a/src/Http/Requests/SubmitFormRequest.php b/src/Http/Requests/SubmitFormRequest.php index 8adc29b..33016f2 100644 --- a/src/Http/Requests/SubmitFormRequest.php +++ b/src/Http/Requests/SubmitFormRequest.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; +use Whilesmart\Forms\Challenges\ChallengeManager; class SubmitFormRequest extends FormRequest { @@ -51,6 +52,33 @@ public function withValidator(Validator $validator): void if (! $hasContent) { $validator->errors()->add('form', 'The form is empty.'); } + + $this->verifyChallenge($validator); }); } + + /** + * A configured challenge provider gets the last word. An unreachable + * provider throws out of here, answering 503 rather than 422. + */ + private function verifyChallenge(Validator $validator): void + { + $verifier = app(ChallengeManager::class)->verifier(); + + if ($verifier === null) { + return; + } + + $token = (string) $this->input($verifier->tokenField(), ''); + + if ($token === '') { + $validator->errors()->add('challenge', 'Please complete the verification challenge.'); + + return; + } + + if (! $verifier->verify($token, $this->ip())) { + $validator->errors()->add('challenge', 'Verification failed. Please try again.'); + } + } } diff --git a/tests/Feature/ChallengeVerificationTest.php b/tests/Feature/ChallengeVerificationTest.php new file mode 100644 index 0000000..1fa6962 --- /dev/null +++ b/tests/Feature/ChallengeVerificationTest.php @@ -0,0 +1,135 @@ + '2026-08-01T15:00:29.021Z', + 'error-codes' => [], + 'hostname' => 'example.com', + 'metadata' => ['result_with_testing_key' => true], + 'success' => true, + ]; + + /** + * Captured from a live siteverify call against the always-failing secret. + */ + private const REJECTED = [ + 'error-codes' => ['invalid-input-response'], + 'success' => false, + 'messages' => [], + 'metadata' => ['result_with_testing_key' => true], + ]; + + protected function defineEnvironment($app) + { + parent::defineEnvironment($app); + + $app['config']->set('eloquent-forms.protection.challenge', 'turnstile'); + $app['config']->set('eloquent-forms.turnstile.secret', 'test-secret'); + } + + #[Test] + public function a_solved_challenge_is_accepted(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + + $this->assertSame(1, FormSubmission::count()); + } + + #[Test] + public function the_challenge_token_is_not_kept_in_the_payload(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + + $this->assertArrayNotHasKey('cf_turnstile_response', FormSubmission::first()->payload); + } + + #[Test] + public function a_rejected_token_is_refused(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::REJECTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'buy now', + 'cf_turnstile_response' => 'a-stale-token', + ])->assertStatus(422); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function a_missing_token_is_refused(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'no challenge solved', + ])->assertStatus(422); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function an_unreachable_provider_answers_service_unavailable(): void + { + Http::fake([self::VERIFY_URL => Http::response('gateway down', 502)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertStatus(503); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function a_missing_secret_answers_service_unavailable(): void + { + config()->set('eloquent-forms.turnstile.secret', null); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertStatus(503); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function no_configured_challenge_leaves_submissions_untouched(): void + { + config()->set('eloquent-forms.protection.challenge', null); + Http::fake(); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + ])->assertCreated(); + + Http::assertNothingSent(); + } +} From 671ed7004dde61641a44597e072c787b1bf10092 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 1 Aug 2026 16:34:37 +0100 Subject: [PATCH 2/6] fix: Expose the allowed-origins restriction through config The origin allowlist was read by the submission controller but had no entry in the shipped config, so it could only ever be set per form. It can now be set once for every form via the environment. --- config/eloquent-forms.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/config/eloquent-forms.php b/config/eloquent-forms.php index 58b87f1..1fb7ad5 100644 --- a/config/eloquent-forms.php +++ b/config/eloquent-forms.php @@ -9,6 +9,15 @@ 'register_routes' => true, 'route_prefix' => env('FORMS_ROUTE_PREFIX', 'api'), + /* + | Hosts permitted to submit, comma-separated. Empty allows any origin. A + | Form row may narrow this further via its `allowed_origins` column. + */ + 'allowed_origins' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('FORMS_ALLOWED_ORIGINS', '')) + ))), + /* | Response envelope used by the package controllers. Swap for your own | implementation of ResponseFormatterInterface to change the shape. From 2bc984e0365bc5c822e96a94f40d6262f1cffa3b Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 1 Aug 2026 17:01:26 +0100 Subject: [PATCH 3/6] chore: Set version to 0.2.0 and update changelog --- CHANGELOG.md | 3 ++- composer.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d42e5df..eb0ce59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## [Unreleased] +## [0.2.0] - 2026-08-01 - Optional human-verification challenge on public submissions, off unless configured - Cloudflare Turnstile ships as the first challenge provider; others plug in behind a challenge contract - A challenge provider that cannot be reached answers 503 rather than rejecting the submitter - The challenge token is verified and discarded instead of being stored with the submission +- The origin allowlist is settable for every form through config, not only per form row ## [0.1.0] - 2026-07-23 - Polymorphic form collection: a Form definition and its submissions, each scoped to any owning model via an owner morph diff --git a/composer.json b/composer.json index 49eaafa..5fe0e30 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Polymorphic form collection with pluggable, agnostic delivery destinations.", "type": "library", "license": "MIT", - "version": "0.1.0", + "version": "0.2.0", "authors": [ { "name": "WhileSmart", From eb85e2a746836e387ca2cc1f67401db50c784e8b Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 1 Aug 2026 17:12:46 +0100 Subject: [PATCH 4/6] fix: Scope the challenge per form so API clients are not locked out A challenge set in config applied to every form and every client. Only a browser can render the widget and produce a token, so enabling one locked out mobile and server-to-server clients posting to the same API, with no way to exempt them. The challenge is now resolved per form, the same way destinations and the origin allowlist already are: the form's own setting wins and an unset one inherits the configured default. A form can opt out of challenges entirely, or require one the default does not. The check also moved to where the form is known, which is where the origin check already runs. A rejected or missing token still answers 422 and an unreachable provider still answers 503. --- CHANGELOG.md | 1 + README.md | 24 ++++++++-- ...01_000001_add_challenge_to_forms_table.php | 23 ++++++++++ src/Challenges/ChallengeManager.php | 7 ++- .../Controllers/FormSubmissionController.php | 33 ++++++++++++- src/Http/Requests/SubmitFormRequest.php | 28 ----------- src/Models/Form.php | 29 +++++++++++- tests/Feature/ChallengeVerificationTest.php | 46 +++++++++++++++++++ 8 files changed, 153 insertions(+), 38 deletions(-) create mode 100644 database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0ce59..2819816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [0.2.0] - 2026-08-01 - Optional human-verification challenge on public submissions, off unless configured - Cloudflare Turnstile ships as the first challenge provider; others plug in behind a challenge contract +- The challenge is set per form as well as by default, so forms submitted by non-browser clients can opt out - A challenge provider that cannot be reached answers 503 rather than rejecting the submitter - The challenge token is verified and discarded instead of being stored with the submission - The origin allowlist is settable for every form through config, not only per form row diff --git a/README.md b/README.md index 38dd76d..782b0ac 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,30 @@ The frontend renders the widget with its own public site key and posts the resulting token as `cf_turnstile_response`. The token is verified server-side, then dropped rather than stored with the submission. -A rejected token fails validation like any other field (422). A provider that -cannot be reached answers 503 instead, so an outage at the provider reads as a -retry rather than as an accusation. +A rejected or missing token answers 422. A provider that cannot be reached +answers 503 instead, so an outage at the provider reads as a retry rather than +as an accusation. Leaving `FORMS_CHALLENGE` unset runs no challenge and makes no outbound call. +### Forms a browser does not submit + +Only a browser can render the widget and produce a token, so a challenge set in +config would otherwise lock out mobile and server-to-server clients posting to +the same API. Set the `challenge` column on those forms to `none`: + +```php +Form::create(['key' => 'mobile-intake', 'challenge' => Form::CHALLENGE_NONE]); +``` + +The column follows the same rule as `destinations` and `allowed_origins`: the +form's own value wins, null inherits the config default. A form can also require +a challenge the default does not, by naming one. + +Forms are created on first use, so a key that has never been submitted inherits +the config default and is challenged. Create the row ahead of the first call for +anything an API client submits. + Add another provider by implementing `Whilesmart\Forms\Contracts\ChallengeVerifier` and registering it in `config('eloquent-forms.challenge_drivers')`. diff --git a/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php b/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php new file mode 100644 index 0000000..cbeff1d --- /dev/null +++ b/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php @@ -0,0 +1,23 @@ +string('challenge')->nullable()->after('allowed_origins'); + }); + } + + public function down(): void + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + } +}; diff --git a/src/Challenges/ChallengeManager.php b/src/Challenges/ChallengeManager.php index 9f48a2c..25be097 100644 --- a/src/Challenges/ChallengeManager.php +++ b/src/Challenges/ChallengeManager.php @@ -13,12 +13,11 @@ public function __construct(private Container $container) } /** - * The configured verifier, or null when no challenge is in use. + * Resolve a challenge key to its verifier. A blank key means the caller + * runs no challenge, so there is nothing to resolve. */ - public function verifier(): ?ChallengeVerifier + public function verifier(?string $key): ?ChallengeVerifier { - $key = config('eloquent-forms.protection.challenge'); - if (blank($key)) { return null; } diff --git a/src/Http/Controllers/FormSubmissionController.php b/src/Http/Controllers/FormSubmissionController.php index 455dcd2..109d051 100644 --- a/src/Http/Controllers/FormSubmissionController.php +++ b/src/Http/Controllers/FormSubmissionController.php @@ -2,9 +2,11 @@ namespace Whilesmart\Forms\Http\Controllers; +use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller; use Illuminate\Support\Str; use Whilesmart\Forms\Challenges\ChallengeManager; +use Whilesmart\Forms\Contracts\ChallengeVerifier; use Whilesmart\Forms\Events\FormSubmittedEvent; use Whilesmart\Forms\Http\Requests\SubmitFormRequest; use Whilesmart\Forms\Jobs\ProcessFormSubmission; @@ -31,9 +33,19 @@ public function store(SubmitFormRequest $request, string $key) return $this->failure('Origin not allowed.', 403); } + $verifier = app(ChallengeManager::class)->verifier($form->challengeKey()); + + if ($verifier !== null) { + $failure = $this->challengeFailure($request, $verifier); + + if ($failure !== null) { + return $failure; + } + } + $payload = $request->except(array_filter([ '_started_at', - app(ChallengeManager::class)->verifier()?->tokenField(), + $verifier?->tokenField(), ])); $submission = new FormSubmission([ @@ -62,6 +74,25 @@ public function store(SubmitFormRequest $request, string $key) ); } + /** + * Null when the challenge was solved. A provider that cannot be reached + * throws instead, answering 503 rather than blaming the submitter. + */ + private function challengeFailure(SubmitFormRequest $request, ChallengeVerifier $verifier): ?JsonResponse + { + $token = (string) $request->input($verifier->tokenField(), ''); + + if ($token === '') { + return $this->failure('Please complete the verification challenge.', 422); + } + + if (! $verifier->verify($token, $request->ip())) { + return $this->failure('Verification failed. Please try again.', 422); + } + + return null; + } + private function originAllowed(SubmitFormRequest $request, Form $form): bool { $allowed = $form->allowed_origins diff --git a/src/Http/Requests/SubmitFormRequest.php b/src/Http/Requests/SubmitFormRequest.php index 33016f2..8adc29b 100644 --- a/src/Http/Requests/SubmitFormRequest.php +++ b/src/Http/Requests/SubmitFormRequest.php @@ -4,7 +4,6 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; -use Whilesmart\Forms\Challenges\ChallengeManager; class SubmitFormRequest extends FormRequest { @@ -52,33 +51,6 @@ public function withValidator(Validator $validator): void if (! $hasContent) { $validator->errors()->add('form', 'The form is empty.'); } - - $this->verifyChallenge($validator); }); } - - /** - * A configured challenge provider gets the last word. An unreachable - * provider throws out of here, answering 503 rather than 422. - */ - private function verifyChallenge(Validator $validator): void - { - $verifier = app(ChallengeManager::class)->verifier(); - - if ($verifier === null) { - return; - } - - $token = (string) $this->input($verifier->tokenField(), ''); - - if ($token === '') { - $validator->errors()->add('challenge', 'Please complete the verification challenge.'); - - return; - } - - if (! $verifier->verify($token, $this->ip())) { - $validator->errors()->add('challenge', 'Verification failed. Please try again.'); - } - } } diff --git a/src/Models/Form.php b/src/Models/Form.php index 0ec9b5c..502bf2c 100644 --- a/src/Models/Form.php +++ b/src/Models/Form.php @@ -9,25 +9,34 @@ /** * A named form definition. Optional: submissions can arrive for a bare key and * a Form row is resolved or created on the fly. When present, a Form row lets - * you scope recipients, allowed origins and destinations per form, and attach - * the form to any owning model (page, product, workspace) polymorphically. + * you scope recipients, allowed origins, destinations and the human-verification + * challenge per form, and attach the form to any owning model (page, product, + * workspace) polymorphically. * * @property string $key * @property string|null $name * @property string|null $recipient_email * @property array|null $destinations * @property array|null $allowed_origins + * @property string|null $challenge * @property bool $is_active * @property array|null $meta */ class Form extends Model { + /** + * Reserved `challenge` value meaning "run no challenge on this form", + * as distinct from null, which inherits the configured default. + */ + public const CHALLENGE_NONE = 'none'; + protected $fillable = [ 'key', 'name', 'recipient_email', 'destinations', 'allowed_origins', + 'challenge', 'is_active', 'meta', ]; @@ -52,6 +61,22 @@ public function submissions(): HasMany return $this->hasMany(FormSubmission::class); } + /** + * Effective challenge key for this form: the form's own when set, otherwise + * the package default. The reserved value `none` opts a form out entirely, + * which is what a form submitted by a non-browser client needs. + */ + public function challengeKey(): ?string + { + $key = $this->challenge ?: config('eloquent-forms.protection.challenge'); + + if (blank($key) || $key === self::CHALLENGE_NONE) { + return null; + } + + return $key; + } + /** * Effective destination keys for this form: the form's own list when set, * otherwise the package default from config. diff --git a/tests/Feature/ChallengeVerificationTest.php b/tests/Feature/ChallengeVerificationTest.php index 1fa6962..796b91c 100644 --- a/tests/Feature/ChallengeVerificationTest.php +++ b/tests/Feature/ChallengeVerificationTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; +use Whilesmart\Forms\Models\Form; use Whilesmart\Forms\Models\FormSubmission; use Whilesmart\Forms\Tests\TestCase; @@ -132,4 +133,49 @@ public function no_configured_challenge_leaves_submissions_untouched(): void Http::assertNothingSent(); } + + #[Test] + public function a_form_can_opt_out_of_the_configured_challenge(): void + { + Form::create(['key' => 'mobile-intake', 'challenge' => Form::CHALLENGE_NONE]); + Http::fake(); + + $this->postJson('/api/forms/mobile-intake/submissions', [ + 'message' => 'posted by a client that cannot render a widget', + ])->assertCreated(); + + Http::assertNothingSent(); + } + + #[Test] + public function opting_one_form_out_leaves_the_others_challenged(): void + { + Form::create(['key' => 'mobile-intake', 'challenge' => Form::CHALLENGE_NONE]); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/mobile-intake/submissions', ['message' => 'no token']) + ->assertCreated(); + + $this->postJson('/api/forms/contact/submissions', ['message' => 'no token']) + ->assertStatus(422); + } + + #[Test] + public function a_form_can_require_a_challenge_the_default_does_not(): void + { + config()->set('eloquent-forms.protection.challenge', null); + Form::create(['key' => 'guarded', 'challenge' => 'turnstile']); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/open/submissions', ['message' => 'no token needed']) + ->assertCreated(); + + $this->postJson('/api/forms/guarded/submissions', ['message' => 'no token']) + ->assertStatus(422); + + $this->postJson('/api/forms/guarded/submissions', [ + 'message' => 'token supplied', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + } } From d0e6821a04e0648d8444f343d4944bebcbca21fa Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 1 Aug 2026 17:40:48 +0100 Subject: [PATCH 5/6] refactor: Inject the challenge manager instead of resolving it inline Matches how the delivery manager is already handed to the job that uses it. --- src/Http/Controllers/FormSubmissionController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/FormSubmissionController.php b/src/Http/Controllers/FormSubmissionController.php index 109d051..530aee3 100644 --- a/src/Http/Controllers/FormSubmissionController.php +++ b/src/Http/Controllers/FormSubmissionController.php @@ -18,7 +18,7 @@ class FormSubmissionController extends Controller { use ApiResponse; - public function store(SubmitFormRequest $request, string $key) + public function store(SubmitFormRequest $request, ChallengeManager $challenges, string $key) { $form = Form::firstOrCreate( ['key' => $key], @@ -33,7 +33,7 @@ public function store(SubmitFormRequest $request, string $key) return $this->failure('Origin not allowed.', 403); } - $verifier = app(ChallengeManager::class)->verifier($form->challengeKey()); + $verifier = $challenges->verifier($form->challengeKey()); if ($verifier !== null) { $failure = $this->challengeFailure($request, $verifier); From d39398b4b3571c348f21999f002cb2f6a468dfea Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 3 Aug 2026 23:08:43 +0100 Subject: [PATCH 6/6] refactor: Store a form's challenge as a document rather than a driver name A single driver name left no room for settings that vary by form, so any provider needing one would have forced another schema change. A form now stores the driver alongside an optional settings bag, which the driver alone interprets. The reserved "none" string is gone: an explicitly empty driver is what opts a form out. Turnstile reads a hostname setting, refusing a token minted for another site. The create migration ships the column in its final shape, so a fresh install gets it in one step. The follow-up migration now applies only where it is needed: it adds the column when an older table lacks it, widens and converts an earlier single-name column, and does nothing when the shape is already right. It keys that decision off narrow string types, because a JSON column reports as text on SQLite and cannot be recognised directly. --- CHANGELOG.md | 2 + README.md | 25 +++++-- .../2026_01_01_000001_create_forms_table.php | 1 + ...01_000001_add_challenge_to_forms_table.php | 56 +++++++++++++-- src/Challenges/TurnstileVerifier.php | 35 +++++++--- src/Contracts/ChallengeVerifier.php | 5 +- .../Controllers/FormSubmissionController.php | 14 ++-- src/Models/Form.php | 51 ++++++++++---- .../Feature/ChallengeColumnMigrationTest.php | 70 +++++++++++++++++++ tests/Feature/ChallengeVerificationTest.php | 38 +++++++++- 10 files changed, 256 insertions(+), 41 deletions(-) create mode 100644 tests/Feature/ChallengeColumnMigrationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2819816..1679232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Optional human-verification challenge on public submissions, off unless configured - Cloudflare Turnstile ships as the first challenge provider; others plug in behind a challenge contract - The challenge is set per form as well as by default, so forms submitted by non-browser clients can opt out +- A form carries its challenge as a driver name plus an optional settings bag, so a provider needing per-form settings does not need a schema change +- Turnstile can be pinned to a hostname per form, refusing a token minted for another site - A challenge provider that cannot be reached answers 503 rather than rejecting the submitter - The challenge token is verified and discarded instead of being stored with the submission - The origin allowlist is settable for every form through config, not only per form row diff --git a/README.md b/README.md index 782b0ac..785d9d4 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,30 @@ Leaving `FORMS_CHALLENGE` unset runs no challenge and makes no outbound call. Only a browser can render the widget and produce a token, so a challenge set in config would otherwise lock out mobile and server-to-server clients posting to -the same API. Set the `challenge` column on those forms to `none`: +the same API. The `challenge` column decides this per form: + +| Stored value | Effect | +| :-- | :----- | +| `null` | inherit the configured default | +| `['driver' => null]` | run no challenge on this form | +| `['driver' => 'turnstile']` | name a driver | +| `['driver' => 'turnstile', 'options' => [...]]` | and pass it settings | ```php -Form::create(['key' => 'mobile-intake', 'challenge' => Form::CHALLENGE_NONE]); +Form::create(['key' => 'mobile-intake', 'challenge' => ['driver' => null]]); ``` -The column follows the same rule as `destinations` and `allowed_origins`: the -form's own value wins, null inherits the config default. A form can also require -a challenge the default does not, by naming one. +A present `driver` entry always wins, including a null one. Anything else falls +through to the default, so the column follows the same rule as `destinations` +and `allowed_origins`. + +`options` belongs to the driver and nothing else interprets it. Turnstile reads +`hostname`, checking it against the host Cloudflare reports for the token so one +minted on another site cannot be replayed: + +```php +'challenge' => ['driver' => 'turnstile', 'options' => ['hostname' => 'whilesmart.com']], +``` Forms are created on first use, so a key that has never been submitted inherits the config default and is challenged. Create the row ahead of the first call for diff --git a/database/migrations/2026_01_01_000001_create_forms_table.php b/database/migrations/2026_01_01_000001_create_forms_table.php index 9f2b679..64a28c9 100644 --- a/database/migrations/2026_01_01_000001_create_forms_table.php +++ b/database/migrations/2026_01_01_000001_create_forms_table.php @@ -14,6 +14,7 @@ public function up(): void $table->string('recipient_email')->nullable(); $table->json('destinations')->nullable(); $table->json('allowed_origins')->nullable(); + $table->json('challenge')->nullable(); $table->boolean('is_active')->default(true); $table->json('meta')->nullable(); $table->nullableMorphs('owner'); diff --git a/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php b/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php index cbeff1d..ce52e1a 100644 --- a/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php +++ b/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php @@ -2,22 +2,66 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +/** + * Brings an existing `forms` table up to the shape the create migration now + * ships. Installs created after that change already have the column, so every + * branch here is conditional and the migration is a no-op for them. + */ return new class () extends Migration { + /** + * Column types that cannot hold a JSON document and so mark the older, + * single-driver shape. A JSON column reports as `json` on MySQL and + * Postgres but as `text` on SQLite, so the check names what must be + * widened rather than what is already correct. + */ + private const NARROW_TYPES = ['varchar', 'char', 'string']; + public function up(): void { + if (! Schema::hasColumn('forms', 'challenge')) { + Schema::table('forms', function (Blueprint $table) { + $table->json('challenge')->nullable()->after('allowed_origins'); + }); + + return; + } + + if (! in_array(Schema::getColumnType('forms', 'challenge'), self::NARROW_TYPES, true)) { + return; + } + + // One row per form definition, so holding them while the column is + // swapped costs nothing and avoids a driver-specific rename. + $existing = DB::table('forms') + ->whereNotNull('challenge') + ->pluck('challenge', 'id'); + Schema::table('forms', function (Blueprint $table) { - // Null inherits the configured default; 'none' opts this form out, - // which is what an API-only form wants. - $table->string('challenge')->nullable()->after('allowed_origins'); + $table->dropColumn('challenge'); }); + + Schema::table('forms', function (Blueprint $table) { + $table->json('challenge')->nullable()->after('allowed_origins'); + }); + + foreach ($existing as $id => $driver) { + DB::table('forms')->where('id', $id)->update([ + 'challenge' => json_encode([ + 'driver' => $driver === 'none' ? null : $driver, + ]), + ]); + } } public function down(): void { - Schema::table('forms', function (Blueprint $table) { - $table->dropColumn('challenge'); - }); + if (Schema::hasColumn('forms', 'challenge')) { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + } } }; diff --git a/src/Challenges/TurnstileVerifier.php b/src/Challenges/TurnstileVerifier.php index b65888e..016ee28 100644 --- a/src/Challenges/TurnstileVerifier.php +++ b/src/Challenges/TurnstileVerifier.php @@ -23,7 +23,14 @@ public function tokenField(): string return config('eloquent-forms.turnstile.token_field', 'cf_turnstile_response'); } - public function verify(string $token, ?string $ipAddress = null): bool + /** + * Recognised options: `hostname`, asserted against the hostname Cloudflare + * reports for the token so a token minted on one site cannot be replayed + * against another. + * + * @param array $options + */ + public function verify(string $token, ?string $ipAddress = null, array $options = []): bool { $secret = config('eloquent-forms.turnstile.secret'); @@ -49,15 +56,27 @@ public function verify(string $token, ?string $ipAddress = null): bool ); } - if ($response->json('success') === true) { - return true; + if ($response->json('success') !== true) { + Log::info('Turnstile rejected a submission.', [ + 'errors' => $response->json('error-codes', []), + 'ip' => $ipAddress, + ]); + + return false; } - Log::info('Turnstile rejected a submission.', [ - 'errors' => $response->json('error-codes', []), - 'ip' => $ipAddress, - ]); + $expectedHostname = $options['hostname'] ?? null; + + if (filled($expectedHostname) && $response->json('hostname') !== $expectedHostname) { + Log::info('Turnstile token was solved for a different hostname.', [ + 'expected' => $expectedHostname, + 'actual' => $response->json('hostname'), + 'ip' => $ipAddress, + ]); + + return false; + } - return false; + return true; } } diff --git a/src/Contracts/ChallengeVerifier.php b/src/Contracts/ChallengeVerifier.php index 58192ef..f9bb912 100644 --- a/src/Contracts/ChallengeVerifier.php +++ b/src/Contracts/ChallengeVerifier.php @@ -14,5 +14,8 @@ interface ChallengeVerifier */ public function tokenField(): string; - public function verify(string $token, ?string $ipAddress = null): bool; + /** + * @param array $options Driver-defined settings for this form. + */ + public function verify(string $token, ?string $ipAddress = null, array $options = []): bool; } diff --git a/src/Http/Controllers/FormSubmissionController.php b/src/Http/Controllers/FormSubmissionController.php index 530aee3..67f0ce7 100644 --- a/src/Http/Controllers/FormSubmissionController.php +++ b/src/Http/Controllers/FormSubmissionController.php @@ -36,7 +36,7 @@ public function store(SubmitFormRequest $request, ChallengeManager $challenges, $verifier = $challenges->verifier($form->challengeKey()); if ($verifier !== null) { - $failure = $this->challengeFailure($request, $verifier); + $failure = $this->challengeFailure($request, $verifier, $form->challengeOptions()); if ($failure !== null) { return $failure; @@ -78,15 +78,21 @@ public function store(SubmitFormRequest $request, ChallengeManager $challenges, * Null when the challenge was solved. A provider that cannot be reached * throws instead, answering 503 rather than blaming the submitter. */ - private function challengeFailure(SubmitFormRequest $request, ChallengeVerifier $verifier): ?JsonResponse - { + /** + * @param array $options + */ + private function challengeFailure( + SubmitFormRequest $request, + ChallengeVerifier $verifier, + array $options, + ): ?JsonResponse { $token = (string) $request->input($verifier->tokenField(), ''); if ($token === '') { return $this->failure('Please complete the verification challenge.', 422); } - if (! $verifier->verify($token, $request->ip())) { + if (! $verifier->verify($token, $request->ip(), $options)) { return $this->failure('Verification failed. Please try again.', 422); } diff --git a/src/Models/Form.php b/src/Models/Form.php index 502bf2c..f7e0777 100644 --- a/src/Models/Form.php +++ b/src/Models/Form.php @@ -18,18 +18,12 @@ * @property string|null $recipient_email * @property array|null $destinations * @property array|null $allowed_origins - * @property string|null $challenge + * @property array|null $challenge * @property bool $is_active * @property array|null $meta */ class Form extends Model { - /** - * Reserved `challenge` value meaning "run no challenge on this form", - * as distinct from null, which inherits the configured default. - */ - public const CHALLENGE_NONE = 'none'; - protected $fillable = [ 'key', 'name', @@ -46,6 +40,7 @@ protected function casts(): array return [ 'destinations' => 'array', 'allowed_origins' => 'array', + 'challenge' => 'array', 'is_active' => 'boolean', 'meta' => 'array', ]; @@ -62,19 +57,47 @@ public function submissions(): HasMany } /** - * Effective challenge key for this form: the form's own when set, otherwise - * the package default. The reserved value `none` opts a form out entirely, - * which is what a form submitted by a non-browser client needs. + * Effective challenge driver for this form, or null to run none. + * + * The stored document names a driver and, optionally, settings for it: + * + * null inherit the configured default + * ['driver' => null] run no challenge on this form + * ['driver' => 'turnstile'] name a driver + * ['driver' => 'turnstile', 'options' => []] and pass it settings + * + * A present `driver` entry always wins, including a null one, which is how + * a form submitted by a non-browser client opts out of an install-wide + * default. Anything else falls through to that default. */ public function challengeKey(): ?string { - $key = $this->challenge ?: config('eloquent-forms.protection.challenge'); + $config = $this->challenge; + + if (is_array($config) && array_key_exists('driver', $config)) { + return blank($config['driver']) ? null : (string) $config['driver']; + } + + $default = config('eloquent-forms.protection.challenge'); + + return blank($default) ? null : (string) $default; + } + + /** + * Settings handed to this form's challenge driver. Their meaning belongs to + * the driver, so nothing here interprets them. + * + * @return array + */ + public function challengeOptions(): array + { + $config = $this->challenge; - if (blank($key) || $key === self::CHALLENGE_NONE) { - return null; + if (! is_array($config) || ! isset($config['options']) || ! is_array($config['options'])) { + return []; } - return $key; + return $config['options']; } /** diff --git a/tests/Feature/ChallengeColumnMigrationTest.php b/tests/Feature/ChallengeColumnMigrationTest.php new file mode 100644 index 0000000..c400b17 --- /dev/null +++ b/tests/Feature/ChallengeColumnMigrationTest.php @@ -0,0 +1,70 @@ +up(); + } + + #[Test] + public function it_is_a_no_op_when_the_column_is_already_present(): void + { + $form = Form::create(['key' => 'contact', 'challenge' => ['driver' => 'turnstile']]); + + $this->runMigration(); + + $this->assertTrue(Schema::hasColumn('forms', 'challenge')); + $this->assertSame(['driver' => 'turnstile'], $form->fresh()->challenge); + } + + #[Test] + public function it_adds_the_column_when_an_older_table_lacks_it(): void + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + $this->assertFalse(Schema::hasColumn('forms', 'challenge')); + + $this->runMigration(); + + $this->assertTrue(Schema::hasColumn('forms', 'challenge')); + + Form::create(['key' => 'contact', 'challenge' => ['driver' => null]]); + $this->assertSame(['driver' => null], Form::first()->challenge); + } + + #[Test] + public function it_widens_a_single_driver_column_and_carries_its_values(): void + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + Schema::table('forms', function (Blueprint $table) { + $table->string('challenge')->nullable(); + }); + + DB::table('forms')->insert([ + ['key' => 'contact', 'challenge' => 'turnstile', 'is_active' => true], + ['key' => 'mobile', 'challenge' => 'none', 'is_active' => true], + ['key' => 'inherits', 'challenge' => null, 'is_active' => true], + ]); + + $this->runMigration(); + + $this->assertSame(['driver' => 'turnstile'], Form::where('key', 'contact')->first()->challenge); + $this->assertSame(['driver' => null], Form::where('key', 'mobile')->first()->challenge); + $this->assertNull(Form::where('key', 'inherits')->first()->challenge); + } +} diff --git a/tests/Feature/ChallengeVerificationTest.php b/tests/Feature/ChallengeVerificationTest.php index 796b91c..5127b22 100644 --- a/tests/Feature/ChallengeVerificationTest.php +++ b/tests/Feature/ChallengeVerificationTest.php @@ -137,7 +137,7 @@ public function no_configured_challenge_leaves_submissions_untouched(): void #[Test] public function a_form_can_opt_out_of_the_configured_challenge(): void { - Form::create(['key' => 'mobile-intake', 'challenge' => Form::CHALLENGE_NONE]); + Form::create(['key' => 'mobile-intake', 'challenge' => ['driver' => null]]); Http::fake(); $this->postJson('/api/forms/mobile-intake/submissions', [ @@ -150,7 +150,7 @@ public function a_form_can_opt_out_of_the_configured_challenge(): void #[Test] public function opting_one_form_out_leaves_the_others_challenged(): void { - Form::create(['key' => 'mobile-intake', 'challenge' => Form::CHALLENGE_NONE]); + Form::create(['key' => 'mobile-intake', 'challenge' => ['driver' => null]]); Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); $this->postJson('/api/forms/mobile-intake/submissions', ['message' => 'no token']) @@ -164,7 +164,7 @@ public function opting_one_form_out_leaves_the_others_challenged(): void public function a_form_can_require_a_challenge_the_default_does_not(): void { config()->set('eloquent-forms.protection.challenge', null); - Form::create(['key' => 'guarded', 'challenge' => 'turnstile']); + Form::create(['key' => 'guarded', 'challenge' => ['driver' => 'turnstile']]); Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); $this->postJson('/api/forms/open/submissions', ['message' => 'no token needed']) @@ -178,4 +178,36 @@ public function a_form_can_require_a_challenge_the_default_does_not(): void 'cf_turnstile_response' => 'a-token-from-the-widget', ])->assertCreated(); } + + #[Test] + public function driver_options_are_carried_from_the_form(): void + { + Form::create([ + 'key' => 'pinned', + 'challenge' => ['driver' => 'turnstile', 'options' => ['hostname' => 'example.com']], + ]); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/pinned/submissions', [ + 'message' => 'solved on the expected host', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + } + + #[Test] + public function a_token_solved_for_another_hostname_is_refused(): void + { + Form::create([ + 'key' => 'pinned', + 'challenge' => ['driver' => 'turnstile', 'options' => ['hostname' => 'whilesmart.com']], + ]); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/pinned/submissions', [ + 'message' => 'token minted elsewhere', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertStatus(422); + + $this->assertSame(0, FormSubmission::count()); + } }