PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.0
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.3.0, at src/component/class-captcha.php

628 lines 18.1 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 if ( ! $this->provider->verify_response( 'default_login' ) ) {
369 if ( is_wp_error( $user ) ) {
370 $user->add( 'invalid_captcha', $this->provider->error_message() );
371
372 return $user;
373 }
374
375 return new WP_Error( 'invalid_captcha', $this->provider->error_message() );
376 }
377
378 return $user;
379 }
380
381 /**
382 * Verify the recaptcha code on the Registration page.
383 *
384 * @param WP_Error $errors A WP_Error object containing any errors encountered during registration.
385 *
386 * @return WP_Error
387 */
388 public function validate_captcha_field_on_registration( WP_Error $errors ) {
389 // Skip check if connecting to XMLRPC.
390 if ( defined( 'XMLRPC_REQUEST' ) ) {
391 return $errors;
392 }
393
394 if ( ! $this->provider->verify_response( 'default_registration' ) ) {
395 $errors->add( 'invalid_captcha', $this->provider->error_message() );
396 }
397 $_POST['g-recaptcha-response-check'] = true;
398
399 return $errors;
400 }
401
402 /**
403 * Add captcha to the multisite signup form.
404 *
405 * @param WP_Error $errors A WP_Error object possibly containing 'blogname' or 'blog_title' errors.
406 *
407 * @return void
408 */
409 public function display_signup_captcha( WP_Error $errors ): void {
410 $error_message = $errors->get_error_message( 'invalid_captcha' );
411 if ( is_string( $error_message ) && '' !== $error_message ) {
412 printf( '<p class="error">%s</p>', wp_kses_post( $error_message ) );
413 }
414 echo wp_kses( $this->display_recaptcha(), $this->get_allowed_html() );
415 }
416
417 /**
418 * Verify the recaptcha code on the multisite signup page.
419 *
420 * @param array $result An array of errors.
421 *
422 * @return array
423 */
424 public function validate_captcha_field_on_wpmu_registration( array $result ): array {
425 global $current_user;
426 if ( is_admin() && ! defined( 'DOING_AJAX' ) && isset( $current_user->data->ID ) && 0 < (int) $current_user->data->ID ) {
427 return $result;
428 }
429
430 // Skip if BuddyPress is handling the registration.
431 if ( $this->buddypress && $this->buddypress->is_activated() && in_array( Buddypress::REGISTER_FORM, $this->model->buddypress_checked_locations, true ) ) {
432 return $result;
433 }
434
435 if ( ! $this->provider->verify_response( 'wpmu_registration' ) ) {
436 if ( isset( $result['errors'] ) && $result['errors'] instanceof WP_Error ) {
437 $errors = $result['errors'];
438 } else {
439 $errors = new WP_Error();
440 }
441 $errors->add( 'invalid_captcha', $this->provider->error_message() );
442 $result['errors'] = $errors;
443
444 return $result;
445 }
446
447 return $result;
448 }
449
450 /**
451 * Verify the recaptcha code on Woo login page.
452 *
453 * @param WP_Error $errors A WP_Error object containing any errors encountered during login.
454 *
455 * @return WP_Error
456 */
457 public function validate_captcha_field_on_woo_login( WP_Error $errors ) {
458 // Skip check if connecting to XMLRPC.
459 if ( defined( 'XMLRPC_REQUEST' ) ) {
460 return $errors;
461 }
462
463 if ( ! $this->provider->verify_response( 'woo_login' ) ) {
464 // Remove 'Error: ' because Woo has it by default.
465 $message = str_replace( sprintf( '<strong>%s:</strong> ', esc_html__( 'Error', 'defender-security' ) ), '', $this->provider->error_message() );
466 $errors->add( 'invalid_captcha', $message );
467 }
468
469 return $errors;
470 }
471
472 /**
473 * Check recaptcha on Woo registration form.
474 *
475 * @param WP_Error $errors A WP_Error object containing any errors encountered during registration.
476 *
477 * @return WP_Error
478 */
479 public function validate_captcha_field_on_woo_registration( WP_Error $errors ) {
480 if ( defined( 'WOOCOMMERCE_CHECKOUT' ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
481 return $errors;
482 }
483 if ( ! $this->provider->verify_response( 'woo_registration' ) ) {
484 // Remove 'Error: ' because Woo has it by default.
485 $message = str_replace( sprintf( '<strong>%s:</strong> ', esc_html__( 'Error', 'defender-security' ) ), '', $this->provider->error_message() );
486 $errors->add( 'invalid_captcha', $message );
487 }
488
489 return $errors;
490 }
491
492 /**
493 * Fires before errors are returned from a password reset request.
494 * Without 2nd `$user_data` parameter because it's since WP 5.4.0.
495 *
496 * @param WP_Error $errors A WP_Error object containing any errors encountered during password reset.
497 *
498 * @return void
499 */
500 public function validate_captcha_field_on_lostpassword( WP_Error $errors ): void {
501 if ( ! $this->provider->verify_response( 'default_lost_password' ) ) {
502 $errors->add( 'invalid_captcha', $this->provider->error_message() );
503 }
504 }
505
506 /**
507 * Validates the reCAPTCHA field on the WooCommerce checkout form.
508 *
509 * @param array $fields The fields of the checkout form.
510 * @param WP_Error $errors The errors encountered during the checkout process.
511 *
512 * @return void
513 */
514 public function validate_captcha_field_on_woo_checkout( $fields, $errors ): void {
515 if ( ! $this->provider->verify_response( 'woo_checkout' ) ) {
516 // Remove 'Error: ' because Woo has it by default.
517 $message = str_replace( sprintf( '<strong>%s:</strong> ', esc_html__( 'Error', 'defender-security' ) ), '', $this->provider->error_message() );
518 $errors->add( 'invalid_captcha', $message );
519 }
520 }
521
522 /**
523 * Display google recaptcha on comments form.
524 *
525 * @param array $defaults The default comment form arguments.
526 *
527 * @return array
528 */
529 public function comment_form_defaults( array $defaults ) {
530 $defaults['comment_notes_after'] .= '<p>' . $this->display_recaptcha() . '</p>';
531
532 return $defaults;
533 }
534
535 /**
536 * Check JS enabled for comment form.
537 *
538 * @param int $comment_post_id Post ID.
539 *
540 * @return void
541 */
542 public function validate_captcha_field_on_comment( $comment_post_id ) {
543 if ( $this->exclude_captcha_for_requests() ) {
544 return;
545 }
546 // Skip if it's from WooCommerce review form.
547 if ( 'product' === get_post_type( $comment_post_id ) ) {
548 return;
549 }
550
551 if ( ! $this->provider->verify_response( 'default_comments' ) ) {
552 // @since v2.5.6
553 wp_die( wp_kses_post( apply_filters( 'wd_recaptcha_require_valid_comment', $this->provider->error_message() ) ) );
554 }
555 }
556
557 /**
558 * Excludes CAPTCHA for specific requests.
559 *
560 * @return bool
561 */
562 public function exclude_captcha_for_requests(): bool {
563 $uri = defender_get_data_from_request( 'REQUEST_URI', 's' ) ?? '/';
564 $excluded_requests = $this->provider->get_excluded_requests();
565
566 return in_array( $uri, $excluded_requests, true );
567 }
568
569 /**
570 * Display the BuddyPress reCAPTCHA.
571 *
572 * @return void
573 */
574 public function display_buddypress_recaptcha(): void {
575 $error = '';
576
577 if ( function_exists( 'buddypress' ) ) {
578 $obj = buddypress();
579 if ( isset( $obj->signup->errors['failed_captcha_verification'] ) && '' !== $obj->signup->errors['failed_captcha_verification'] ) {
580 $error = $obj->signup->errors['failed_captcha_verification'];
581 }
582 }
583
584 if ( '' !== $error ) {
585 $output = '<div class="error">';
586 $output .= $error;
587 $output .= '</div>';
588
589 echo wp_kses_post( $output );
590 }
591
592 echo wp_kses( $this->display_recaptcha(), $this->get_allowed_html() );
593 }
594
595 /**
596 * Validates the reCAPTCHA field on the BuddyPress registration form.
597 *
598 * @return void
599 */
600 public function validate_captcha_field_on_buddypress_registration(): void {
601 if ( ! $this->provider->verify_response( 'buddypress_registration' ) && function_exists( 'buddypress' ) ) {
602 buddypress()->signup->errors['failed_captcha_verification'] = $this->provider->error_message();
603 }
604 }
605
606 /**
607 * Verify BuddyPress group form captcha.
608 *
609 * @return void
610 */
611 public function validate_captcha_field_on_buddypress_group(): void {
612 if ( function_exists( 'bp_is_group_creation_step' ) && ! bp_is_group_creation_step( 'group-details' ) ) {
613 return;
614 }
615
616 if (
617 ! $this->provider->verify_response( 'buddypress_create_group' )
618 && function_exists( 'bp_core_add_message' )
619 && function_exists( 'bp_core_redirect' )
620 && function_exists( 'bp_get_root_domain' )
621 && function_exists( 'bp_get_groups_root_slug' )
622 ) {
623 bp_core_add_message( $this->provider->error_message(), 'error' );
624 bp_core_redirect( bp_get_root_domain() . '/' . bp_get_groups_root_slug() . '/create/step/group-details/' );
625 }
626 }
627 }
628