PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.4
Defender Security – Malware Scanner, Login Security & Firewall v6.2.4
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / component / class-captcha.php

class-captcha.php in Defender Security – Malware Scanner, Login Security & Firewall 6.2.4, at src/component/class-captcha.php

634 lines 18.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles CAPTCHA functionality.
4 *
5 * @package WP_Defender\Component
6 */
7
8 namespace WP_Defender\Component;
9
10 use WP_Error;
11 use Calotes\Base\Component;
12 use WP_Defender\Integrations\Buddypress;
13 use WP_Defender\Integrations\Woocommerce;
14 use WP_Defender\Component\Captcha\Turnstile;
15 use WP_Defender\Component\Captcha\Recaptcha;
16 use WP_Defender\Component\Captcha\Interface_Provider;
17 use WP_Defender\Model\Setting\Captcha as Captcha_Model;
18
19 /**
20 * Provides methods to handle CAPTCHA integration, including rendering, validation, and script management.
21 */
22 class Captcha extends Component {
23
24 /**
25 * Default form identifiers for CAPTCHA integration.
26 */
27 public const DEFAULT_LOGIN_FORM = 'login', DEFAULT_REGISTER_FORM = 'register', DEFAULT_LOST_PASSWORD_FORM = 'lost_password', DEFAULT_COMMENT_FORM = 'comments';
28
29 /**
30 * The CAPTCHA settings model.
31 *
32 * @var Captcha_Model
33 */
34 protected Captcha_Model $model;
35
36 /**
37 * WooCommerce integration instance.
38 *
39 * @var Woocommerce|null
40 */
41 private ?Woocommerce $woo;
42 /**
43 * BuddyPress integration instance.
44 *
45 * @var Buddypress|null
46 */
47 private ?Buddypress $buddypress;
48 /**
49 * Active CAPTCHA provider instance.
50 *
51 * @var Interface_Provider|null
52 */
53 private ?Interface_Provider $provider = null;
54
55 /**
56 * Captcha constructor.
57 *
58 * @param Captcha_Model $model The CAPTCHA settings model.
59 */
60 public function __construct( Captcha_Model $model ) {
61 $this->model = $model;
62 $this->woo = wd_di()->get( Woocommerce::class );
63 $this->buddypress = wd_di()->get( Buddypress::class );
64 $this->provider = $this->get_provider();
65 }
66
67 /**
68 * Get the active captcha provider.
69 *
70 * @return Interface_Provider
71 */
72 public function get_provider(): Interface_Provider {
73 if ( 'recaptcha' === $this->model->provider && ! $this->provider instanceof Recaptcha ) {
74 $this->provider = new Recaptcha( $this->model );
75 }
76 if ( Captcha_Model::TURNSTILE === $this->model->provider && ! $this->provider instanceof Turnstile ) {
77 $this->provider = new Turnstile( $this->model );
78 }
79 // Fallback to Recaptcha if provider is invalid.
80 if ( null === $this->provider ) {
81 $this->provider = new Recaptcha( $this->model );
82 }
83 return $this->provider;
84 }
85
86 /**
87 * Retrieves the list of default forms where CAPTCHA can be integrated.
88 *
89 * @return array An associative array of form identifiers and their display names.
90 */
91 public static function get_forms(): array {
92 return array(
93 self::DEFAULT_LOGIN_FORM => esc_html__( 'Login', 'defender-security' ),
94 self::DEFAULT_REGISTER_FORM => esc_html__( 'Register', 'defender-security' ),
95 self::DEFAULT_LOST_PASSWORD_FORM => esc_html__( 'Lost Password', 'defender-security' ),
96 self::DEFAULT_COMMENT_FORM => esc_html__( 'Comments', 'defender-security' ),
97 );
98 }
99
100 /**
101 * Determines if any CAPTCHA location is enabled.
102 *
103 * @param bool $exist_woo Whether WooCommerce is active.
104 * @param bool $exist_bp Whether BuddyPress is active.
105 *
106 * @return bool True if any location is enabled, false otherwise.
107 */
108 public function enable_any_location( bool $exist_woo, bool $exist_bp ): bool {
109 return $this->model->enable_default_location() || $this->model->check_woo_locations( $exist_woo ) || $this->model->check_buddypress_locations( $exist_bp );
110 }
111
112 /**
113 * Modifies the script loader tag for the 'wpdef_captcha_api' handle.
114 *
115 * @param string $tag The original script loader tag.
116 * @param string $handle The handle being loaded.
117 *
118 * @return string The modified script loader tag.
119 */
120 public function script_loader_tag( string $tag, string $handle ): string {
121 if ( 'wpdef_captcha_api' === $handle ) {
122 $tag = str_replace( ' src', ' data-cfasync="false" async="async" defer="defer" src', $tag );
123 }
124
125 return $tag;
126 }
127
128 /**
129 * Add scripts when comments are lazy loaded.
130 *
131 * @return void
132 * @since 2.6.1
133 */
134 public function add_scripts_for_lazy_load(): void {
135 if ( in_array(
136 $this->provider->type,
137 array(
138 'v2_checkbox',
139 'v2_invisible',
140 ),
141 true
142 ) && ( is_single() || is_page() ) && comments_open() ) {
143 if ( ! wp_script_is( 'wpdef_captcha_api', 'registered' ) ) {
144 $api_url = $this->provider->get_api_url();
145 $deps = array( 'jquery' );
146 wp_register_script( 'wpdef_captcha_api', $api_url, $deps, DEFENDER_VERSION, true );
147 }
148
149 $this->add_captcha_scripts();
150 }
151 }
152
153 /**
154 * Enqueues the necessary scripts for the reCAPTCHA frontend.
155 *
156 * @return void
157 */
158 public function add_captcha_scripts(): void {
159 if ( isset( $this->provider->type ) ) {
160 $this->remove_duplicate_captcha_scripts();
161 }
162 $handle = $this->provider->get_script_handle();
163 $src_url = $this->provider->get_script_url();
164 if ( ! wp_script_is( $handle ) ) {
165 wp_enqueue_script(
166 $handle,
167 plugins_url( $src_url, WP_DEFENDER_FILE ),
168 array(
169 'jquery',
170 'wpdef_captcha_api',
171 ),
172 DEFENDER_VERSION,
173 true
174 );
175 }
176
177 // @since 2.5.6
178 do_action( 'wd_recaptcha_extra_assets' );
179 wp_localize_script(
180 $handle,
181 'WPDEF',
182 array(
183 'options' => $this->provider->get_options(),
184 'vars' => array(
185 'visibility' => ( 'login_footer' === current_filter() ),
186 ),
187 )
188 );
189 }
190
191 /**
192 * Removes duplicate CAPTCHA scripts.
193 *
194 * @return void
195 */
196 public function remove_duplicate_captcha_scripts(): void {
197 global $wp_scripts;
198
199 if ( ! is_object( $wp_scripts ) ) {
200 return;
201 }
202
203 $excluded_handles = $this->provider->get_duplicate_script_handles();
204 $search_pattern = $this->provider->get_duplicate_script_pattern();
205 foreach ( $wp_scripts->registered as $script_name => $args ) {
206 if ( is_string( $args->src ) && preg_match( $search_pattern, $args->src ) && ! in_array( $script_name, $excluded_handles, true ) ) {
207 wp_dequeue_script( $script_name );
208 }
209 }
210 }
211
212 /**
213 * Display the CAPTCHA field.
214 *
215 * @return void
216 */
217 public function display_login_captcha(): void {
218 if ( in_array( $this->provider->type, array( 'v2_checkbox', Captcha_Model::TURNSTILE ), true ) ) {
219 $from_width = 302; ?>
220 <style media="screen">
221 .login-action-login #loginform,
222 .login-action-lostpassword #lostpasswordform,
223 .login-action-register #registerform {
224 width: <?php echo esc_attr( $from_width ); ?>px !important;
225 }
226
227 #login_error,
228 .message {
229 width: <?php echo esc_attr( $from_width + 20 ); ?>px !important;
230 }
231
232 .login-action-login #loginform .captcha_wrap,
233 .login-action-lostpassword #lostpasswordform .captcha_wrap,
234 .login-action-register #registerform .captcha_wrap {
235 margin-bottom: 10px;
236 }
237
238 #group-create-body .captcha_wrap {
239 margin-top: 15px;
240 }
241 </style>
242 <?php
243 } elseif ( 'v2_invisible' === $this->provider->type ) {
244 ?>
245 <style>
246 .login-action-lostpassword #lostpasswordform .captcha_wrap,
247 .login-action-login #loginform .captcha_wrap,
248 .login-action-register #registerform .captcha_wrap {
249 margin-bottom: 10px;
250 }
251
252 #signup-content .captcha_wrap,
253 #group-create-body .captcha_wrap {
254 margin-top: 10px;
255 }
256 </style>
257 <?php
258 }
259 echo wp_kses( $this->display_recaptcha(), $this->get_allowed_html() );
260 }
261
262 /**
263 * Display the output of the recaptcha.
264 *
265 * @return string
266 */
267 protected function display_recaptcha(): string {
268 $content = $this->provider->render_widget();
269 // Register reCAPTCHA script.
270 $locations = $this->model->locations;
271 if ( ! wp_script_is( 'wpdef_captcha_api', 'registered' ) ) {
272 $script = $this->provider->get_api_script();
273 wp_register_script( 'wpdef_captcha_api', $script['url'], $script['deps'], $script['version'], false );
274 add_action( 'wp_footer', array( $this, 'add_captcha_scripts' ) );
275 if ( in_array( self::DEFAULT_LOGIN_FORM, $locations, true ) || in_array( self::DEFAULT_REGISTER_FORM, $locations, true ) || in_array( self::DEFAULT_LOST_PASSWORD_FORM, $locations, true ) ) {
276 add_action( 'login_footer', array( $this, 'add_captcha_scripts' ) );
277 }
278 }
279
280 return $content;
281 }
282
283 /**
284 * Get allowed HTML tags and attributes for reCaptcha output sanitization.
285 *
286 * @return array
287 */
288 private function get_allowed_html(): array {
289 return array(
290 'div' => array(
291 'class' => array(),
292 'id' => array(),
293 'style' => array(),
294 ),
295 'iframe' => array(
296 'src' => array(),
297 'frameborder' => array(),
298 'scrolling' => array(),
299 'style' => array(),
300 ),
301 'noscript' => array(),
302 'textarea' => array(
303 'name' => array( 'g-recaptcha-response' ),
304 'class' => array( 'g-recaptcha-response' ),
305 'style' => array(),
306 ),
307 'input' => array(
308 'type' => array( 'hidden' ),
309 'class' => array( 'g-recaptcha-response' ),
310 'name' => array( 'g-recaptcha-response' ),
311 ),
312 );
313 }
314
315 /**
316 * Validates the CAPTCHA response for the login form.
317 *
318 * @param null|WP_Error $error WP_Error object if validation fails, else null.
319 *
320 * @return null|WP_Error WP_Error object if validation fails else null.
321 */
322 public function validate_login_captcha( $error ) {
323 $post_data = defender_get_data_from_request( null, 'p' );
324 if ( array() === $post_data ) {
325 return $error;
326 }
327 // Skip turnstile verification for current sprint plan.
328 if ( $this->provider->should_skip_check( $this->woo, $this->buddypress ) ) {
329 return $error;
330 }
331 // Check if the $_POST array is not empty and if 'g-recaptcha-response' key is also empty.
332 $captcha_response = $this->provider->get_response();
333 if ( '' === $captcha_response ) {
334 $code = 'captcha_error';
335 $message = __( 'Please verify that you are not a robot.', 'defender-security' );
336
337 if ( is_wp_error( $error ) ) {
338 $error->add( $code, $message );
339 } else {
340 // Replace $user with a new WP_Error object with an error message.
341 $error = new WP_Error( $code, $message );
342 }
343 }
344
345 // Return the $error variable.
346 return $error;
347 }
348
349 /**
350 * Verify the captcha code on the Login page.
351 *
352 * @param WP_User|WP_Error $user WP_User or WP_Error object if a previous callback failed authentication.
353 *
354 * @return WP_Error|WP_User
355 */
356 public function validate_captcha_field_on_login( $user ) {
357 if ( $this->woo && $this->woo->is_woocommerce_page() ) {
358 return $user;
359 }
360 // Skip check if connecting to XMLRPC.
361 if ( defined( 'XMLRPC_REQUEST' ) ) {
362 return $user;
363 }
364 // Skip turnstile verification for current sprint plan.
365 if ( $this->provider->should_skip_check( $this->woo, $this->buddypress ) ) {
366 return $user;
367 }
368 // Is Recaptcha-request from 'Ultimate Member' plugin?
369 $um_request = defender_get_data_from_request( 'um_request', 'p' );
370 if ( is_string( $um_request ) && '' !== $um_request && function_exists( 'um_recaptcha_validate' ) ) {
371 return $user;
372 }
373
374 if ( ! $this->provider->verify_response( 'default_login' ) ) {
375 if ( is_wp_error( $user ) ) {
376 $user->add( 'invalid_captcha', $this->provider->error_message() );
377
378 return $user;
379 }
380
381 return new WP_Error( 'invalid_captcha', $this->provider->error_message() );
382 }
383
384 return $user;
385 }
386
387 /**
388 * Verify the recaptcha code on the Registration page.
389 *
390 * @param WP_Error $errors A WP_Error object containing any errors encountered during registration.
391 *
392 * @return WP_Error
393 */
394 public function validate_captcha_field_on_registration( WP_Error $errors ) {
395 // Skip check if connecting to XMLRPC.
396 if ( defined( 'XMLRPC_REQUEST' ) ) {
397 return $errors;
398 }
399
400 if ( ! $this->provider->verify_response( 'default_registration' ) ) {
401 $errors->add( 'invalid_captcha', $this->provider->error_message() );
402 }
403 $_POST['g-recaptcha-response-check'] = true;
404
405 return $errors;
406 }
407
408 /**
409 * Add captcha to the multisite signup form.
410 *
411 * @param WP_Error $errors A WP_Error object possibly containing 'blogname' or 'blog_title' errors.
412 *
413 * @return void
414 */
415 public function display_signup_captcha( WP_Error $errors ): void {
416 $error_message = $errors->get_error_message( 'invalid_captcha' );
417 if ( is_string( $error_message ) && '' !== $error_message ) {
418 printf( '<p class="error">%s</p>', wp_kses_post( $error_message ) );
419 }
420 echo wp_kses( $this->display_recaptcha(), $this->get_allowed_html() );
421 }
422
423 /**
424 * Verify the recaptcha code on the multisite signup page.
425 *
426 * @param array $result An array of errors.
427 *
428 * @return array
429 */
430 public function validate_captcha_field_on_wpmu_registration( array $result ): array {
431 global $current_user;
432 if ( is_admin() && ! defined( 'DOING_AJAX' ) && isset( $current_user->data->ID ) && 0 < (int) $current_user->data->ID ) {
433 return $result;
434 }
435
436 // Skip if BuddyPress is handling the registration.
437 if ( $this->buddypress && $this->buddypress->is_activated() && in_array( Buddypress::REGISTER_FORM, $this->model->buddypress_checked_locations, true ) ) {
438 return $result;
439 }
440
441 if ( ! $this->provider->verify_response( 'wpmu_registration' ) ) {
442 if ( isset( $result['errors'] ) && $result['errors'] instanceof WP_Error ) {
443 $errors = $result['errors'];
444 } else {
445 $errors = new WP_Error();
446 }
447 $errors->add( 'invalid_captcha', $this->provider->error_message() );
448 $result['errors'] = $errors;
449
450 return $result;
451 }
452
453 return $result;
454 }
455
456 /**
457 * Verify the recaptcha code on Woo login page.
458 *
459 * @param WP_Error $errors A WP_Error object containing any errors encountered during login.
460 *
461 * @return WP_Error
462 */
463 public function validate_captcha_field_on_woo_login( WP_Error $errors ) {
464 // Skip check if connecting to XMLRPC.
465 if ( defined( 'XMLRPC_REQUEST' ) ) {
466 return $errors;
467 }
468
469 if ( ! $this->provider->verify_response( 'woo_login' ) ) {
470 // Remove 'Error: ' because Woo has it by default.
471 $message = str_replace( sprintf( '<strong>%s:</strong> ', esc_html__( 'Error', 'defender-security' ) ), '', $this->provider->error_message() );
472 $errors->add( 'invalid_captcha', $message );
473 }
474
475 return $errors;
476 }
477
478 /**
479 * Check recaptcha on Woo registration form.
480 *
481 * @param WP_Error $errors A WP_Error object containing any errors encountered during registration.
482 *
483 * @return WP_Error
484 */
485 public function validate_captcha_field_on_woo_registration( WP_Error $errors ) {
486 if ( defined( 'WOOCOMMERCE_CHECKOUT' ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
487 return $errors;
488 }
489 if ( ! $this->provider->verify_response( 'woo_registration' ) ) {
490 // Remove 'Error: ' because Woo has it by default.
491 $message = str_replace( sprintf( '<strong>%s:</strong> ', esc_html__( 'Error', 'defender-security' ) ), '', $this->provider->error_message() );
492 $errors->add( 'invalid_captcha', $message );
493 }
494
495 return $errors;
496 }
497
498 /**
499 * Fires before errors are returned from a password reset request.
500 * Without 2nd `$user_data` parameter because it's since WP 5.4.0.
501 *
502 * @param WP_Error $errors A WP_Error object containing any errors encountered during password reset.
503 *
504 * @return void
505 */
506 public function validate_captcha_field_on_lostpassword( WP_Error $errors ): void {
507 if ( ! $this->provider->verify_response( 'default_lost_password' ) ) {
508 $errors->add( 'invalid_captcha', $this->provider->error_message() );
509 }
510 }
511
512 /**
513 * Validates the reCAPTCHA field on the WooCommerce checkout form.
514 *
515 * @param array $fields The fields of the checkout form.
516 * @param WP_Error $errors The errors encountered during the checkout process.
517 *
518 * @return void
519 */
520 public function validate_captcha_field_on_woo_checkout( $fields, $errors ): void {
521 if ( ! $this->provider->verify_response( 'woo_checkout' ) ) {
522 // Remove 'Error: ' because Woo has it by default.
523 $message = str_replace( sprintf( '<strong>%s:</strong> ', esc_html__( 'Error', 'defender-security' ) ), '', $this->provider->error_message() );
524 $errors->add( 'invalid_captcha', $message );
525 }
526 }
527
528 /**
529 * Display google recaptcha on comments form.
530 *
531 * @param array $defaults The default comment form arguments.
532 *
533 * @return array
534 */
535 public function comment_form_defaults( array $defaults ) {
536 $defaults['comment_notes_after'] .= '<p>' . $this->display_recaptcha() . '</p>';
537
538 return $defaults;
539 }
540
541 /**
542 * Check JS enabled for comment form.
543 *
544 * @param int $comment_post_id Post ID.
545 *
546 * @return void
547 */
548 public function validate_captcha_field_on_comment( $comment_post_id ) {
549 if ( $this->exclude_captcha_for_requests() ) {
550 return;
551 }
552 // Skip if it's from WooCommerce review form.
553 if ( 'product' === get_post_type( $comment_post_id ) ) {
554 return;
555 }
556
557 if ( ! $this->provider->verify_response( 'default_comments' ) ) {
558 // @since v2.5.6
559 wp_die( wp_kses_post( apply_filters( 'wd_recaptcha_require_valid_comment', $this->provider->error_message() ) ) );
560 }
561 }
562
563 /**
564 * Excludes CAPTCHA for specific requests.
565 *
566 * @return bool
567 */
568 public function exclude_captcha_for_requests(): bool {
569 $uri = defender_get_data_from_request( 'REQUEST_URI', 's' ) ?? '/';
570 $excluded_requests = $this->provider->get_excluded_requests();
571
572 return in_array( $uri, $excluded_requests, true );
573 }
574
575 /**
576 * Display the BuddyPress reCAPTCHA.
577 *
578 * @return void
579 */
580 public function display_buddypress_recaptcha(): void {
581 $error = '';
582
583 if ( function_exists( 'buddypress' ) ) {
584 $obj = buddypress();
585 if ( isset( $obj->signup->errors['failed_captcha_verification'] ) && '' !== $obj->signup->errors['failed_captcha_verification'] ) {
586 $error = $obj->signup->errors['failed_captcha_verification'];
587 }
588 }
589
590 if ( '' !== $error ) {
591 $output = '<div class="error">';
592 $output .= $error;
593 $output .= '</div>';
594
595 echo wp_kses_post( $output );
596 }
597
598 echo wp_kses( $this->display_recaptcha(), $this->get_allowed_html() );
599 }
600
601 /**
602 * Validates the reCAPTCHA field on the BuddyPress registration form.
603 *
604 * @return void
605 */
606 public function validate_captcha_field_on_buddypress_registration(): void {
607 if ( ! $this->provider->verify_response( 'buddypress_registration' ) && function_exists( 'buddypress' ) ) {
608 buddypress()->signup->errors['failed_captcha_verification'] = $this->provider->error_message();
609 }
610 }
611
612 /**
613 * Verify BuddyPress group form captcha.
614 *
615 * @return void
616 */
617 public function validate_captcha_field_on_buddypress_group(): void {
618 if ( function_exists( 'bp_is_group_creation_step' ) && ! bp_is_group_creation_step( 'group-details' ) ) {
619 return;
620 }
621
622 if (
623 ! $this->provider->verify_response( 'buddypress_create_group' )
624 && function_exists( 'bp_core_add_message' )
625 && function_exists( 'bp_core_redirect' )
626 && function_exists( 'bp_get_root_domain' )
627 && function_exists( 'bp_get_groups_root_slug' )
628 ) {
629 bp_core_add_message( $this->provider->error_message(), 'error' );
630 bp_core_redirect( bp_get_root_domain() . '/' . bp_get_groups_root_slug() . '/create/step/group-details/' );
631 }
632 }
633 }
634