Turnstile for Elementor Pro forms

brkicpc

New member
Hi,

I needed Turnstile on Elementor Pro's Form widget, so I built it as a module in the style of your existing Turnstile ones, against 1.10.0. Sharing it in case you want it.

The gap​

Elementor Pro only ships reCAPTCHA v2/v3 natively, but it already hard codes a turnstile field slug in its own validation strip lists (form-record.php, the skip_all_strip_types default). It clearly expects a third party to provide one. Your MetForm module hooks the same elementor/widget/render_content filter, but it checks for MetForm's button widget, so Elementor Pro's own Form widget isn't covered.

What's attached​

One new file plus three small diffs:
  • ElementorPro.php, goes in src/DigitalPoint/Cloudflare/Turnstile/
  • setup-defaults.diff: 'onElementorPro' => 0 default in Setup.php
  • pub-wiring.diff: isPluginActive gate in Base/Pub.php
  • settings-toggle.diff: checkbox in the Contact forms group in Settings.php
It reuses your plumbing throughout: addTurnstileScript() on the same turnstile handle, addTurnstileHtml(), getTurnstileResponse(), generateError() so the strings and translations match the other integrations, and getCloudflareRepo()->verifyTurnstileResponse(). No HTTP or options code of its own, and it's PHP 5.4 clean.

Why it's built the way it is​

A few things are specific to how Elementor works and worth knowing about:
  1. The token is read from $_POST via getTurnstileResponse(), not from the Form_Record. Elementor rebuilds the form definition from the stored post meta at submit time, so an injected widget never appears in the record. Nice side effect: the token can't leak into the email or the Submissions table.
  2. There's an explicit editor/preview bail. The Elementor editor canvas and the preview iframe are ordinary frontend requests, so the render filter runs in both.
  3. The widget is rendered explicitly against a unique per widget id instead of relying on the implicit .cf-turnstile scan. Elementor submits over AJAX and never reloads the page, and tokens are single use with a 300 second life, so every attempt needs a turnstile.reset(), which needs the widgetId that turnstile.render() returns. The implicit scan doesn't hand one back. The reset is bound to Elementor's own reset/error form events (the same ones their native reCAPTCHA uses), plus elementor/popup/show for popup forms.
  4. Errors go through $ajaxHandler->add_error_message() rather than add_error(), because an injected widget has no #form-field-{id} anchor for a per field error to attach to. It renders as a form level message instead.
  5. The script handle is registered (and the inline script attached) on every request via wp_enqueue_scripts, not just at render time. Elementor's element cache serves widget HTML from cache and re-enqueues that page's scripts by handle only, without running render_content again. If the handle isn't registered on a cache hit, the widget on a cached page silently never renders while validation stays live. Registration is weightless until something actually enqueues it.
There's also a captcha coexistence check on both sides: if the form already has a recaptcha, recaptcha_v3, hcaptcha or turnstile field, both the injection and the validation skip it. Those two guards have to stay symmetric, because if only the injection skips, the form can never be submitted.

Things I noticed along the way​

  • Turnstile/MetForm.php hardcodes id="cf-turnstile" and calls turnstile.render("#cf-turnstile") unconditionally, so on a page with more than one MetForm only the first widget renders. The same str_replace I'm using, with the widget id folded into the id, fixes it.
  • The app_for_cf_turnstile_forms filter is a really nice extension point and I couldn't find it documented anywhere. I'm using it in production right now to bridge this until it hopefully lands natively: add the toggle to the array and sanitizeSettings persists the checkbox with no extra save code. Might deserve a readme mention next to the other app_for_cf_* filters.
  • Optional and entirely take it or leave it: the Turnstile docs recommend also validating hostname (and action where one is set) in the siteverify response. All six current integrations check only success, so I kept parity rather than making this module behave differently, but it'd be a reasonable plugin wide hardening.
  • Unrelated trap worth knowing about: enabling onLogin on a site that uses Elementor Pro's Login widget locks users out. That widget posts to wp-login.php without a Turnstile widget ever being rendered on the page. Not something I've tried to fix.

Testing​

Verified so far: the widget renders once per form, placed just before the submit button; api.js loads exactly once; both failure paths via direct POSTs to admin-ajax.php come back with the right errors ("No Turnstile response" with no token, "Invalid Turnstile challenge" with a garbage one, which also proves the siteverify round trip); and cached pages serve the widget correctly.

Thanks for the plugin, it's doing a lot of work for us.
 

Attachments

Back
Top