PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / admin / security / modules / captcha / class-charitable-captcha-cloudflare-turnstile.php

class-charitable-captcha-cloudflare-turnstile.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/admin/security/modules/captcha/class-charitable-captcha-cloudflare-turnstile.php

486 lines 14.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The main Charitable CAPTCHA class.
4 *
5 * @package Charitable/Classes/Charitable_Captcha_Cloudflare_Turnstile
6 * @copyright Copyright (c) 2023, WP Charitable LLC
7 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
8 * @version 1.8.9
9 * @since 1.8.9
10 */
11
12 // Exit if accessed directly.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 if ( ! class_exists( 'Charitable_Captcha_Cloudflare_Turnstile' ) ) :
18
19 /**
20 * Cloudflare Turnstile module.
21 *
22 * @since 1.8.9
23 */
24 class Charitable_Captcha_Cloudflare_Turnstile {
25
26 /**
27 * Class constructor.
28 *
29 * @since 1.8.9
30 */
31 public function __construct() {
32 if ( charitable_is_debug() && charitable_is_debug( 'security' ) ) {
33 $provider = charitable_get_option( 'captcha_provider', 'not_set' );
34 $site_key = $this->get_site_key();
35 $secret_key = $this->get_secret_key();
36 error_log( '[Charitable CAPTCHA Turnstile] Constructor called | Provider: ' . $provider . ' | Site Key: ' . ( ! empty( $site_key ) ? 'SET' : 'EMPTY' ) . ' | Secret Key: ' . ( ! empty( $secret_key ) ? 'SET' : 'EMPTY' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
37 }
38 if ( $this->is_active() ) {
39 if ( charitable_is_debug() && charitable_is_debug( 'security' ) ) {
40 error_log( '[Charitable CAPTCHA Turnstile] Module is active, calling setup()' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
41 }
42 $this->setup();
43 } elseif ( charitable_is_debug() && charitable_is_debug( 'security' ) ) {
44 error_log( '[Charitable CAPTCHA Turnstile] Module is NOT active, setup() not called' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
45 }
46 }
47
48 /**
49 * Return whether the module is active.
50 *
51 * @since 1.8.0
52 *
53 * @return boolean
54 */
55 public function is_active() {
56 return 'cloudflare-turnstile' === charitable_get_option( 'captcha_provider' )
57 && ! empty( $this->get_site_key() )
58 && ! empty( $this->get_secret_key() );
59 }
60
61 /**
62 * Get the module settings.
63 *
64 * @since 1.8.0
65 *
66 * @return array
67 */
68 public function get_settings() {
69 return array(
70 'cloudflare_turnstile_site_key' => array(
71 'title' => __( 'Cloudflare Turnstile Site Key', 'charitable' ),
72 'type' => 'text',
73 'class' => 'wide',
74 'help' => __( 'Your Cloudflare Turnstile "Site key" setting. Find this in <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank">your Cloudflare dashboard</a>.', 'charitable' ),
75 'priority' => 152,
76 'attrs' => array(
77 'data-trigger-key' => '#charitable_settings_captcha_provider',
78 'data-trigger-value' => 'cloudflare-turnstile',
79 'style' => 'width: 100%;',
80 ),
81 ),
82 'cloudflare_turnstile_secret_key' => array(
83 'title' => __( 'Cloudflare Turnstile Secret Key', 'charitable' ),
84 'type' => 'text',
85 'class' => 'wide',
86 'help' => __( 'Your Cloudflare Turnstile "Secret key" setting. Find this in <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank">your Cloudflare dashboard</a>.', 'charitable' ),
87 'priority' => 156,
88 'attrs' => array(
89 'data-trigger-key' => '#charitable_settings_captcha_provider',
90 'data-trigger-value' => 'cloudflare-turnstile',
91 'style' => 'width: 100%;',
92 ),
93 ),
94 );
95 }
96
97 /**
98 * Get the site key.
99 *
100 * @since 1.8.0
101 *
102 * @return string
103 */
104 public function get_site_key() {
105 return charitable_get_option( 'cloudflare_turnstile_site_key' );
106 }
107
108 /**
109 * Get the secret key.
110 *
111 * @since 1.8.0
112 *
113 * @return string
114 */
115 public function get_secret_key() {
116 return charitable_get_option( 'cloudflare_turnstile_secret_key' );
117 }
118
119 /**
120 * Set up module hooks.
121 *
122 * @since 1.8.0
123 *
124 * @return void
125 */
126 public function setup() {
127 add_action( 'wp_enqueue_scripts', array( $this, 'add_scripts' ), 15 );
128 add_action( 'charitable_form_after_fields', array( $this, 'add_turnstile_to_form' ) );
129
130 /* For the donation form, validate token as part of the security check. */
131 add_filter( 'charitable_validate_donation_form_submission_security_check', array( $this, 'validate_token_for_donation' ), 10, 2 );
132
133 /**
134 * For the password retrieval, password reset, profile and registration
135 * forms, we check turnstile before the regular form processor occurs.
136 *
137 * If the turnstile check fails, we prevent further processing.
138 */
139 add_action( 'charitable_retrieve_password', array( $this, 'check_turnstile_before_form_processing' ), 1 );
140 add_action( 'charitable_reset_password', array( $this, 'check_turnstile_before_form_processing' ), 1 );
141 add_action( 'charitable_update_profile', array( $this, 'check_turnstile_before_form_processing' ), 1 );
142 add_action( 'charitable_save_registration', array( $this, 'check_turnstile_before_form_processing' ), 1 );
143 }
144
145 /**
146 * Add Cloudflare Turnstile scripts.
147 *
148 * @since 1.8.0
149 *
150 * @return void
151 */
152 public function add_scripts() {
153 // Cloudflare Turnstile requires loading from their CDN - this is the official API endpoint.
154 wp_enqueue_script(
155 'charitable-cloudflare-turnstile',
156 'https://challenges.cloudflare.com/turnstile/v0/api.js', // phpcs:ignore PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
157 array(),
158 'v0',
159 true
160 );
161 }
162
163 /**
164 * Add Cloudflare Turnstile widget to the form.
165 *
166 * @since 1.8.0
167 *
168 * @param Charitable_Form $form A form object.
169 * @return void
170 */
171 public function add_turnstile_to_form( Charitable_Form $form ) {
172 if ( charitable_is_debug() && charitable_is_debug( 'security' ) ) {
173 $form_class = get_class( $form );
174 $form_key = $this->get_current_form_from_class( $form );
175 $is_enabled = $this->is_enabled_for_form( $form_key );
176 error_log( '[Charitable CAPTCHA Turnstile] add_turnstile_to_form() called | Form Class: ' . $form_class . ' | Form Key: ' . ( $form_key ? $form_key : 'NULL' ) . ' | Enabled: ' . ( $is_enabled ? 'YES' : 'NO' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
177 }
178
179 /* Don't show captcha for logged in users. */
180 if ( is_user_logged_in() && 'no' === charitable_get_option( 'captcha_logged_in', 'no' ) ) {
181 if ( charitable_is_debug() && charitable_is_debug( 'security' ) ) {
182 error_log( '[Charitable CAPTCHA Turnstile] Skipping - user is logged in and captcha_logged_in is "no"' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
183 }
184 return;
185 }
186
187 if ( $this->is_enabled_for_form( $this->get_current_form_from_class( $form ) ) ) {
188 if ( charitable_is_debug() && charitable_is_debug( 'security' ) ) {
189 error_log( '[Charitable CAPTCHA Turnstile] Adding Turnstile widget to form' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
190 }
191 $form_key = $this->get_current_form_from_class( $form );
192 $action = $this->get_action_for_form( $form_key );
193
194 ob_start();
195 ?>
196 <div class="charitable-turnstile cf-turnstile" data-sitekey="<?php echo esc_attr( $this->get_site_key() ); ?>" data-action="charitable-form-<?php echo esc_attr( $action ); ?>"></div>
197 <?php
198 echo ob_get_clean(); // phpcs:ignore
199 }
200 }
201
202 /**
203 * Return the current form key based on the class name.
204 *
205 * @since 1.8.0
206 *
207 * @param Charitable_Form $form A form object.
208 * @return string|null Form key if it's a supported form. Null otherwise.
209 */
210 public function get_current_form_from_class( Charitable_Form $form ) {
211 switch ( get_class( $form ) ) {
212 case 'Charitable_Registration_Form':
213 $form_key = 'registration_form';
214 break;
215
216 case 'Charitable_Profile_Form':
217 $form_key = 'profile_form';
218 break;
219
220 case 'Charitable_Forgot_Password_Form':
221 $form_key = 'password_retrieval_form';
222 break;
223
224 case 'Charitable_Reset_Password_Form':
225 $form_key = 'password_reset_form';
226 break;
227
228 case 'Charitable_Donation_Form':
229 $form_key = 'donation_form';
230 break;
231
232 case 'Charitable_Donation_Amount_Form':
233 $form_key = 'donation_amount_form';
234 break;
235
236 case 'Charitable_Ambassadors_Campaign_Form':
237 $form_key = 'campaign_form';
238 break;
239
240 default:
241 $form_key = null;
242 }
243
244 return $form_key;
245 }
246
247 /**
248 * Return the form key based on the hook.
249 *
250 * @since 1.8.0
251 *
252 * @return string|null
253 */
254 public function get_current_form_from_hook() {
255 switch ( current_filter() ) {
256 case 'charitable_save_registration':
257 $form_key = 'registration_form';
258 break;
259
260 case 'charitable_update_profile':
261 $form_key = 'profile_form';
262 break;
263
264 case 'charitable_retrieve_password':
265 $form_key = 'password_retrieval_form';
266 break;
267
268 case 'charitable_reset_password':
269 $form_key = 'password_reset_form';
270 break;
271
272 case 'charitable_save_campaign':
273 $form_key = 'campaign_form';
274 break;
275
276 default:
277 $form_key = null;
278 }
279 return $form_key;
280 }
281
282 /**
283 * Get action name for form.
284 *
285 * @since 1.8.0
286 *
287 * @param string $form_key The form key.
288 * @return string
289 */
290 public function get_action_for_form( $form_key ) {
291 switch ( $form_key ) {
292 case 'donation_form':
293 return 'donation';
294 case 'registration_form':
295 return 'registration';
296 case 'password_reset_form':
297 return 'password_reset';
298 case 'password_retrieval_form':
299 return 'pwd_retrieval';
300 case 'profile_form':
301 return 'profile';
302 case 'campaign_form':
303 return 'campaign';
304 default:
305 return 'form';
306 }
307 }
308
309 /**
310 * Returns whether Turnstile is enabled for the given form.
311 *
312 * @since 1.8.0
313 *
314 * @param string|null $form_key The key of the form, or NULL if it's not a supported one.
315 * @return boolean
316 */
317 public function is_enabled_for_form( $form_key ) {
318 if ( is_null( $form_key ) ) {
319 return false;
320 }
321
322 $forms = $this->get_form_settings();
323
324 return isset( $forms[ $form_key ] ) && $forms[ $form_key ];
325 }
326
327 /**
328 * Returns an array of forms that Turnstile can be enabled for.
329 *
330 * @since 1.8.0
331 *
332 * @return array
333 */
334 public function get_form_settings() {
335 /**
336 * Returns an array of forms along with whether Turnstile
337 * is enabled for them. By default, all forms are enabled.
338 *
339 * @since 1.8.9
340 *
341 * @param array $forms All the supported forms in a key=>value array, where the value is either
342 * true (Turnstile is enabled) or false (Turnstile is disabled).
343 */
344 return apply_filters(
345 'charitable_cloudflare_turnstile_forms',
346 array(
347 'donation_form' => true,
348 'donation_amount_form' => false,
349 'registration_form' => true,
350 'password_reset_form' => true,
351 'password_retrieval_form' => true,
352 'profile_form' => false,
353 'campaign_form' => true,
354 )
355 );
356 }
357
358 /**
359 * Validate the Turnstile token on donation form submission.
360 *
361 * @since 1.8.0
362 *
363 * @param boolean $ret The result to be returned. True or False.
364 * @param Charitable_Donation_Form $form The donation form object.
365 * @return boolean
366 */
367 public function validate_token_for_donation( $ret, Charitable_Donation_Form $form ) {
368 if ( ! $ret ) {
369 return $ret;
370 }
371
372 /* Don't show captcha for logged in users. */
373 if ( is_user_logged_in() && 'no' === charitable_get_option( 'captcha_logged_in', 'no' ) ) {
374 return $ret;
375 }
376
377 if ( ! $this->is_enabled_for_form( $this->get_current_form_from_class( $form ) ) ) {
378 return $ret;
379 }
380
381 return $this->is_captcha_valid( 'donation' );
382 }
383
384 /**
385 * Check token validity before processing a form submission.
386 *
387 * If the check fails, form processing is blocked.
388 *
389 * @since 1.8.0
390 *
391 * @return void
392 */
393 public function check_turnstile_before_form_processing() {
394 /* Don't show captcha for logged in users. */
395 if ( is_user_logged_in() && 'no' === charitable_get_option( 'captcha_logged_in', 'no' ) ) {
396 return;
397 }
398
399 $form_key = $this->get_current_form_from_hook();
400
401 if ( ! $this->is_enabled_for_form( $form_key ) ) {
402 return;
403 }
404
405 $action = $this->get_action_for_form( $form_key );
406
407 /* Captcha token isn't valid. */
408 if ( ! $this->is_captcha_valid( $action ) ) {
409 switch ( $form_key ) {
410 case 'registration_form':
411 remove_action( 'charitable_save_registration', array( 'Charitable_Registration_Form', 'save_registration' ) );
412 break;
413
414 case 'profile_form':
415 remove_action( 'charitable_update_profile', array( 'Charitable_Profile_Form', 'update_profile' ) );
416 break;
417
418 case 'password_retrieval_form':
419 remove_action( 'charitable_retrieve_password', array( 'Charitable_Forgot_Password_Form', 'retrieve_password' ) );
420 break;
421
422 case 'password_reset_form':
423 remove_action( 'charitable_reset_password', array( 'Charitable_Reset_Password_Form', 'reset_password' ) );
424 break;
425
426 case 'campaign_form':
427 remove_action( 'charitable_save_campaign', array( 'Charitable_Ambassadors_Campaign_Form', 'save_campaign' ) );
428 break;
429 }
430 }
431 }
432
433 /**
434 * Returns whether the captcha is valid.
435 *
436 * @since 1.8.0
437 *
438 * @param string $action The form action name.
439 * @return boolean
440 */
441 public function is_captcha_valid( $action = 'form' ) {
442 // Cloudflare Turnstile automatically creates a hidden input with name 'cf-turnstile-response'.
443 if ( ! isset( $_POST['cf-turnstile-response'] ) || empty( $_POST['cf-turnstile-response'] ) ) { // phpcs:ignore
444 charitable_get_notices()->add_error( __( 'Missing captcha token.', 'charitable' ) );
445 return false;
446 }
447
448 $token = sanitize_text_field( wp_unslash( $_POST['cf-turnstile-response'] ) ); // phpcs:ignore
449
450 $response = wp_remote_post(
451 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
452 array(
453 'body' => array(
454 'secret' => $this->get_secret_key(),
455 'response' => $token,
456 'remoteip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', // phpcs:ignore
457 ),
458 )
459 );
460
461 if ( is_wp_error( $response ) ) {
462 charitable_get_notices()->add_error( __( 'Failed to verify captcha.', 'charitable' ) );
463 return false;
464 }
465
466 $result = json_decode( wp_remote_retrieve_body( $response ), true );
467
468 if ( ! isset( $result['success'] ) || ! $result['success'] ) {
469 charitable_get_notices()->add_error( __( 'Captcha validation failed.', 'charitable' ) );
470 return false;
471 }
472
473 // Check if action matches (optional but recommended).
474 $expected_action = 'charitable-form-' . $action;
475 if ( isset( $result['action'] ) && $result['action'] !== $expected_action ) {
476 charitable_get_notices()->add_error( __( 'Captcha action mismatch.', 'charitable' ) );
477 return false;
478 }
479
480 return true;
481 }
482 }
483
484 endif;
485
486