Skip to content

feat: Add optional human-verification challenge to form submissions - #2

Open
nfebe wants to merge 6 commits into
devfrom
feat/challenge-verification
Open

feat: Add optional human-verification challenge to form submissions#2
nfebe wants to merge 6 commits into
devfrom
feat/challenge-verification

Conversation

@nfebe

@nfebe nfebe commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Wiring the contact form on whilesmart.com, the app was about to hand-roll Turnstile verification in its own middleware. It belongs here so the next project doesn't repeat it.

Submissions can optionally require a solved challenge. Off unless configured, Turnstile first, other providers behind a contract.

Only a browser can produce a token, so the challenge is set per form as well as by default, the way destinations and the origin allowlist already are. Worth knowing: forms are created on first use, so a key never submitted before inherits the default and gets challenged. An API-only form needs its row created ahead of the first call.

Bumped to 0.2.0 because whilesmart/pages needs a release to depend on this.

nfebe added 4 commits August 1, 2026 16:20
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.
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.
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.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Coverage Report
PR coverage: 92.66%
Baseline: 0%
Change: ✅+92.7%

@sourceant

sourceant Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review Summary

This PR introduces a robust and flexible human-verification challenge system (Turnstile) to the form submission package. It supports global defaults, per-form overrides, and a clean opt-out mechanism for API clients. The error handling for external provider outages is particularly well-conceived.

🚀 Key Improvements

  • Added Cloudflare Turnstile as the default challenge provider.
  • Implemented a per-form opt-out mechanism (['driver' => null]) to support non-browser clients while keeping global protection on.
  • Ensured challenge tokens are verified and then discarded from the stored submission payload for privacy and cleanliness.
  • Graceful handling of provider outages by returning a 503 'Service Unavailable' response.
  • Included a comprehensive migration strategy for upgrading existing installations.

💡 Minor Suggestions

  • Merge redundant docblocks in the controller.
  • Use more specific filtering for HTTP request parameters to avoid accidental omission of falsy values.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

nfebe added 2 commits August 1, 2026 17:40
Matches how the delivery manager is already handed to the job that uses it.
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant