Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [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 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

## [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
Expand Down
69 changes: 62 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -30,8 +27,66 @@ 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 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. 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' => ['driver' => null]]);
```

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
anything an API client submits.

Add another provider by implementing
`Whilesmart\Forms\Contracts\ChallengeVerifier` and registering it in
`config('eloquent-forms.challenge_drivers')`.

## Destinations

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions config/eloquent-forms.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -34,6 +43,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),
],

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

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) {
$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
{
if (Schema::hasColumn('forms', 'challenge')) {
Schema::table('forms', function (Blueprint $table) {
$table->dropColumn('challenge');
});
}
}
};
39 changes: 39 additions & 0 deletions src/Challenges/ChallengeManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Whilesmart\Forms\Challenges;

use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
use Whilesmart\Forms\Contracts\ChallengeVerifier;

class ChallengeManager
{
public function __construct(private Container $container)
{
}

/**
* 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(?string $key): ?ChallengeVerifier
{
if (blank($key)) {
return null;
}

$map = config('eloquent-forms.challenge_drivers', []);

if (! isset($map[$key])) {
throw new InvalidArgumentException("Form challenge [{$key}] is not registered.");
}

$instance = $this->container->make($map[$key]);

if (! $instance instanceof ChallengeVerifier) {
throw new InvalidArgumentException("Form challenge [{$key}] must implement ChallengeVerifier.");
}

return $instance;
}
}
82 changes: 82 additions & 0 deletions src/Challenges/TurnstileVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Whilesmart\Forms\Challenges;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
use Whilesmart\Forms\Contracts\ChallengeVerifier;
use Whilesmart\Forms\Exceptions\ChallengeUnavailableException;

/**
* Cloudflare Turnstile. The site key is public and belongs in the frontend
* bundle; only the secret is read here.
*
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*/
class TurnstileVerifier implements ChallengeVerifier
{
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';

public function tokenField(): string
{
return config('eloquent-forms.turnstile.token_field', 'cf_turnstile_response');
}

/**
* 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<string, mixed> $options
*/
public function verify(string $token, ?string $ipAddress = null, array $options = []): bool
{
$secret = config('eloquent-forms.turnstile.secret');

if (blank($secret)) {
throw new ChallengeUnavailableException('Turnstile secret is not configured.');
}

try {
$response = Http::asForm()
->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) {
Log::info('Turnstile rejected a submission.', [
'errors' => $response->json('error-codes', []),
'ip' => $ipAddress,
]);

return false;
}

$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 true;
}
}
21 changes: 21 additions & 0 deletions src/Contracts/ChallengeVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Whilesmart\Forms\Contracts;

/**
* A human-verification challenge solved in the browser and confirmed here.
* Implementations return false for a token the provider rejects, and throw
* when the provider itself could not be reached.
*/
interface ChallengeVerifier
{
/**
* Name of the request field carrying the provider's token.
*/
public function tokenField(): string;

/**
* @param array<string, mixed> $options Driver-defined settings for this form.
*/
public function verify(string $token, ?string $ipAddress = null, array $options = []): bool;
}
Loading
Loading