PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / trunk
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More vtrunk
6.35 6.34 6.33.1 6.33 6.32.1 6.32 6.31 6.25 6.25.1 6.26 6.26.1 6.27 6.28 6.29 6.3 6.3.1 6.3.2 6.30 6.4 6.4.1 6.4.2 6.5 6.5.1 6.5.2 6.5.3 All 141 releases
formidable / classes / models / FrmSpamCheckDenylist.php

FrmSpamCheckDenylist.php in Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More trunk, at classes/models/FrmSpamCheckDenylist.php

739 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Spam check using denylist
4 *
5 * @since 6.21
6 *
7 * @package Formidable
8 */
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 die( 'You are not allowed to call this page directly.' );
12 }
13
14 class FrmSpamCheckDenylist extends FrmSpamCheck {
15
16 const COMPARE_CONTAINS = '';
17
18 const COMPARE_EQUALS = 'equals';
19
20 /**
21 * How many leading characters of a denylist line are used as its index key.
22 * Four measured best on the shipped denylists: shorter keys are not selective
23 * enough, and longer ones push more lines below the length the index needs.
24 *
25 * @since 6.35
26 */
27 const PREFIX_LENGTH = 4;
28
29 /**
30 * Values shorter than this are not indexed. Comparing them is already cheap
31 * enough that building the index would cost more than it saves.
32 *
33 * @since 6.35
34 */
35 const MIN_LENGTH_TO_INDEX = 1024;
36
37 /**
38 * Values longer than this are not indexed, so the index cannot grow without
39 * bound on an unusually large submission.
40 *
41 * @since 6.35
42 */
43 const MAX_LENGTH_TO_INDEX = 524288;
44
45 /**
46 * @var array|null
47 */
48 protected $posted_fields;
49
50 /**
51 * @var array
52 */
53 protected $denylist;
54
55 /**
56 * @param array $values
57 */
58 public function __construct( $values ) {
59 $this->maybe_add_form_id_to_values( $values );
60
61 parent::__construct( $values );
62
63 $this->denylist = $this->get_denylist_array();
64 }
65
66 /**
67 * @return array
68 */
69 protected function get_posted_fields() {
70 if ( is_null( $this->posted_fields ) ) {
71 $this->posted_fields = FrmField::get_all_for_form( $this->values['form_id'] );
72 }
73 return $this->posted_fields;
74 }
75
76 /**
77 * Maybe add form ID to values. In file name validation, only item_meta in $values.
78 *
79 * @param array $values Spam check values.
80 *
81 * @return void
82 */
83 protected function maybe_add_form_id_to_values( &$values ) {
84 if ( ! empty( $values['form_id'] ) || empty( $values['item_meta'] ) ) {
85 return;
86 }
87
88 $field_id = key( $values['item_meta'] );
89 $field = FrmField::getOne( $field_id );
90
91 if ( $field ) {
92 $values['form_id'] = $field->form_id;
93 }
94 }
95
96 protected function is_enabled() {
97 $frm_settings = FrmAppHelper::get_settings();
98 $is_enabled = $frm_settings->denylist_check;
99
100 /**
101 * Allows disabling the denylist check.
102 *
103 * @since 6.21
104 *
105 * @param bool $is_enabled Whether the denylist check is enabled.
106 * @param array $values The entry values.
107 */
108 return apply_filters( 'frm_check_denylist', $is_enabled, $this->values );
109 }
110
111 /**
112 * Gets denylist data.
113 * See {@see FrmSpamCheckDenylist::fill_default_denylist_data()} for more details.
114 *
115 * @return array[]
116 */
117 protected function get_denylist_array() {
118 $denylist_data = array(
119 array(
120 'file' => FrmAppHelper::plugin_path() . '/denylist/domain-partial.txt',
121 ),
122 array(
123 'file' => FrmAppHelper::plugin_path() . '/denylist/splorp-wp-comment.txt',
124 'skip' => current_user_can( 'frm_create_entries' ),
125 'skip_field_types' => array( 'file' ),
126 ),
127 array(
128 'words' => array(
129 'moncler|north face|vuitton|handbag|burberry|outlet|prada|cialis|viagra|maillot|oakley|ralph lauren|ray ban|iphone|プラダ',
130 ),
131 'field_types' => array( 'name' ),
132 'is_regex' => true,
133 ),
134 array(
135 'words' => array(
136 '@mail\.ru|@yandex\.',
137 ),
138 'field_types' => array( 'email' ),
139 'is_regex' => true,
140 ),
141 );
142
143 $custom_denylist = $this->get_words_from_setting( 'disallowed_words' );
144
145 if ( $custom_denylist ) {
146 $denylist_data['custom'] = array(
147 'words' => $custom_denylist,
148 );
149 }
150
151 /**
152 * Allows to modify the denylist data.
153 *
154 * @since 6.21
155 *
156 * @param array[] $denylist_data The denylist data.
157 */
158 return apply_filters( 'frm_denylist_data', $denylist_data );
159 }
160
161 /**
162 * Gets denylist IP addresses.
163 *
164 * @return array
165 */
166 protected function get_denylist_ips() {
167 return apply_filters(
168 'frm_denylist_ips_data',
169 array(
170 'files' => array(
171 FrmAppHelper::plugin_path() . '/denylist/ip.txt',
172 ),
173 'custom' => array(),
174 )
175 );
176 }
177
178 /**
179 * Checks spam.
180 *
181 * @return bool
182 */
183 public function check() {
184 return $this->check_ip() ? true : $this->check_values();
185 }
186
187 /**
188 * Checks entry values.
189 *
190 * @return bool
191 */
192 protected function check_values() {
193 $allowed_words = $this->get_words_from_setting( 'allowed_words' );
194 $allowed_words = array_map( array( $this, 'convert_to_lowercase' ), $allowed_words );
195
196 foreach ( $this->denylist as $denylist ) {
197 if ( ! empty( $denylist['skip'] ) ) {
198 continue;
199 }
200
201 if ( empty( $denylist['file'] ) && empty( $denylist['words'] ) ) {
202 continue;
203 }
204
205 $this->fill_default_denylist_data( $denylist );
206 $denylist['allowed_words'] = $allowed_words;
207
208 if ( ! $this->add_values_to_check( $denylist ) ) {
209 // Nothing in this submission needs to be checked against this denylist.
210 continue;
211 }
212
213 if ( ! empty( $denylist['words'] ) ) {
214 foreach ( $denylist['words'] as $word ) {
215 if ( $this->single_line_check_values( $word, $denylist ) ) {
216 self::add_spam_keyword_to_option( $word );
217 return true;
218 }
219 }
220 } elseif ( file_exists( $denylist['file'] ) ) {
221 $is_spam = $this->read_lines_and_check( $denylist['file'], array( $this, 'single_line_check_values' ), $denylist );
222
223 if ( $is_spam ) {
224 return true;
225 }
226 }
227 }//end foreach
228
229 return false;
230 }
231
232 /**
233 * Fills default denylist data.
234 *
235 * @param array $denylist Denylist.
236 *
237 * @return void
238 */
239 protected function fill_default_denylist_data( &$denylist ) {
240 $denylist = wp_parse_args(
241 $denylist,
242 array(
243 'file' => '',
244 'words' => array(),
245 'is_regex' => false,
246 'field_types' => array(),
247 // Add `other` if you want to skip checking Other values of some field types.
248 'skip_field_types' => array(),
249 // Is ignore if `is_regex` is `true`.
250 'compare' => self::COMPARE_CONTAINS,
251 'extract_value' => '',
252 // If this is `true`, this denylist will be skipped.
253 'skip' => false,
254 )
255 );
256
257 // Some field types should never be checked.
258 $denylist['skip_field_types'] = array_merge(
259 $denylist['skip_field_types'],
260 array( 'password', 'captcha', 'signature', 'checkbox', 'radio', 'select', 'ranking' )
261 );
262 }
263
264 /**
265 * Extracts the submitted values this denylist needs to check, and the string
266 * forms those values are compared against.
267 *
268 * The values depend on the denylist configuration, not on the word or file line
269 * being compared, so they are extracted once here and carried on the denylist.
270 * The shipped denylist files hold tens of thousands of lines and a large form
271 * posts more than a thousand values, so extracting per line is quadratic.
272 *
273 * @since 6.35
274 *
275 * @param array $denylist Denylist data, with the defaults already filled in.
276 *
277 * @return bool False if this submission has no values for this denylist to check.
278 */
279 protected function add_values_to_check( &$denylist ) {
280 $values_to_check = $this->get_values_to_check( $denylist );
281
282 if ( ! $values_to_check ) {
283 return false;
284 }
285
286 $values_string = $this->convert_values_to_string( $values_to_check );
287
288 $denylist['values_to_check'] = $values_to_check;
289 $denylist['values_string'] = $values_string;
290 $denylist['values_string_lower'] = $this->convert_to_lowercase( $values_string );
291 $denylist['values_prefix_index'] = $this->get_values_prefix_index( $denylist );
292
293 return true;
294 }
295
296 /**
297 * Indexes every PREFIX_LENGTH character window of the values.
298 *
299 * A line can only be inside the values if its own first PREFIX_LENGTH
300 * characters are somewhere in them, so a line whose prefix is missing from
301 * this index cannot match and does not need to be compared at all. The shipped
302 * denylists hold tens of thousands of lines and each comparison reads the whole
303 * values string, so ruling a line out with one array lookup is worth the index.
304 *
305 * Returns an empty array when the index would not answer for this denylist, or
306 * would not pay for itself. Every line is then compared as before.
307 *
308 * @since 6.35
309 *
310 * @param array $denylist Denylist data, holding the values strings.
311 *
312 * @return array Index of value prefixes, or an empty array for no index.
313 */
314 protected function get_values_prefix_index( $denylist ) {
315 if ( ! empty( $denylist['is_regex'] ) || self::COMPARE_CONTAINS !== $denylist['compare'] ) {
316 // A regex line is a pattern rather than a literal, so its leading
317 // characters are not text to look for. Only "contains" is indexable.
318 return array();
319 }
320
321 $values = $denylist['values_string_lower'];
322 $length = strlen( $values );
323
324 if ( $length < self::MIN_LENGTH_TO_INDEX || $length > self::MAX_LENGTH_TO_INDEX ) {
325 return array();
326 }
327
328 $index = array();
329 $last = $length - self::PREFIX_LENGTH;
330
331 for ( $i = 0; $i <= $last; $i++ ) {
332 $index[ substr( $values, $i, self::PREFIX_LENGTH ) ] = true;
333 }
334
335 return $index;
336 }
337
338 /**
339 * Checks the values index to rule a line out before comparing it.
340 *
341 * A `false` here does not mean the line matches, only that the index cannot
342 * rule it out, so the caller still has to compare it.
343 *
344 * @since 6.35
345 *
346 * @param string $line The lowercased denylist line.
347 * @param array $args Check args, holding the index when there is one.
348 *
349 * @return bool True when the line cannot be inside the values.
350 */
351 protected function line_is_ruled_out( $line, $args ) {
352 if ( empty( $args['values_prefix_index'] ) || strlen( $line ) < self::PREFIX_LENGTH ) {
353 return false;
354 }
355
356 return ! isset( $args['values_prefix_index'][ substr( $line, 0, self::PREFIX_LENGTH ) ] );
357 }
358
359 /**
360 * Gets words from setting.
361 *
362 * @param string $setting_key Setting key.
363 *
364 * @return array
365 */
366 protected function get_words_from_setting( $setting_key ) {
367 $frm_settings = FrmAppHelper::get_settings();
368 $words = $frm_settings->$setting_key ?? '';
369
370 if ( ! $words ) {
371 return array();
372 }
373
374 return array_filter(
375 array_map( 'trim', explode( "\n", $words ) )
376 );
377 }
378
379 /**
380 * Checks the values against each single word.
381 *
382 * @param string $line Single line.
383 * @param array $args Check args. Carries the values to check when they have
384 * already been extracted by {@see FrmSpamCheckDenylist::add_values_to_check()}.
385 *
386 * @return bool
387 */
388 protected function single_line_check_values( $line, $args ) {
389 $line = $this->convert_to_lowercase( $line );
390
391 // Do not check if this word is in the allowed words.
392 if ( ! empty( $args['allowed_words'] ) && in_array( $line, $args['allowed_words'], true ) ) {
393 return false;
394 }
395
396 if ( ! isset( $args['values_to_check'] ) && ! $this->add_values_to_check( $args ) ) {
397 // Nothing needs to be checked.
398 return false;
399 }
400
401 if ( ! empty( $args['is_regex'] ) ) {
402 return preg_match( '/' . trim( $line, '/' ) . '/i', $args['values_string'] );
403 }
404
405 if ( self::COMPARE_EQUALS === $args['compare'] ) {
406 foreach ( $args['values_to_check'] as $value ) {
407 $value = $this->convert_to_lowercase( $value );
408
409 if ( $line === $value ) {
410 return true;
411 }
412 }
413
414 return false;
415 }
416
417 if ( $this->line_is_ruled_out( $line, $args ) ) {
418 return false;
419 }
420
421 return str_contains( $args['values_string_lower'], $line );
422 }
423
424 /**
425 * Converts values to string to check.
426 *
427 * @param array $values Values array.
428 *
429 * @return string
430 */
431 protected function convert_values_to_string( $values ) {
432 // Unslash the forward slashes so strings like /joomla/ are not stuck as \/joomla\/.
433 return str_replace( '\\/', '/', FrmAppHelper::maybe_json_encode( $values ) );
434 }
435
436 /**
437 * Converts string to lowercase.
438 *
439 * @param string $str String.
440 *
441 * @return string
442 */
443 protected function convert_to_lowercase( $str ) {
444 return strtolower( $str );
445 }
446
447 /**
448 * Get the field IDs to check.
449 *
450 * @param array $denylist The denylist data.
451 *
452 * @return array|false Return array of field IDs or false if do not need to check.
453 */
454 protected function get_field_ids_to_check( array $denylist ) {
455 $field_types = isset( $denylist['field_types'] ) && is_array( $denylist['field_types'] ) ? $denylist['field_types'] : array();
456 $skip_field_types = isset( $denylist['skip_field_types'] ) && is_array( $denylist['skip_field_types'] ) ? $denylist['skip_field_types'] : array();
457
458 if ( ! $field_types && ! $skip_field_types ) {
459 // This will check all fields.
460 return false;
461 }
462
463 $field_ids_to_check = array();
464
465 foreach ( $this->get_posted_fields() as $field ) {
466 $field_type = FrmField::get_field_type( $field );
467
468 if ( in_array( $field_type, $skip_field_types, true ) ) {
469 continue;
470 }
471
472 if ( $field_types && ! in_array( $field_type, $field_types, true ) ) {
473 continue;
474 }
475
476 $field_ids_to_check[] = intval( $field->id );
477 }
478
479 return $field_ids_to_check;
480 }
481
482 /**
483 * Gets values to check.
484 *
485 * @param array $denylist Single denylist data.
486 *
487 * @return array|false Return `false` if no values need to check, or return array of values.
488 */
489 protected function get_values_to_check( $denylist ) {
490 $field_ids_to_check = $this->get_field_ids_to_check( $denylist );
491
492 if ( array() === $field_ids_to_check ) {
493 // No values need to check.
494 return false;
495 }
496
497 $values_to_check = array();
498
499 foreach ( $this->values['item_meta'] as $key => $value ) {
500 if ( is_array( $value ) && isset( $value['form'] ) ) {
501 // This is a repeater value, loop through sub values.
502 unset( $value['form'] );
503 unset( $value['row_ids'] );
504
505 foreach ( $value as $sub_key => $sub_value ) {
506 if ( $this->should_check_this_field( $sub_key, $field_ids_to_check ) ) {
507 $this->add_to_values_to_check( $values_to_check, $sub_value );
508 }
509 }
510 } elseif ( 'other' === $key ) {
511 if ( ! in_array( 'other', $denylist['skip_field_types'], true ) ) {
512 // This is Other values, loop through this and add sub values.
513 foreach ( $value as $sub_value ) {
514 $this->add_to_values_to_check( $values_to_check, $sub_value );
515 }
516 }
517 } elseif ( $this->should_check_this_field( $key, $field_ids_to_check ) ) {
518 $this->add_to_values_to_check( $values_to_check, $value );
519 }
520 }//end foreach
521
522 if ( isset( $denylist['extract_value'] ) && is_callable( $denylist['extract_value'] ) ) {
523 return call_user_func( $denylist['extract_value'], $values_to_check, $denylist );
524 }
525
526 return $values_to_check;
527 }
528
529 /**
530 * Checks if should check the value of the given field ID.
531 *
532 * @param int $field_id Field ID.
533 * @param false|int[] $field_ids_to_check Field IDs to check.
534 *
535 * @return bool
536 */
537 protected function should_check_this_field( $field_id, $field_ids_to_check ) {
538 // Should check this field if no field types is specific or this field ID is in the field IDs to check array.
539 return false === $field_ids_to_check || in_array( $field_id, $field_ids_to_check, true );
540 }
541
542 /**
543 * Adds the value to values to check array.
544 *
545 * @param array $values_to_check Values to check array.
546 * @param mixed $value The value.
547 *
548 * @return void
549 */
550 protected function add_to_values_to_check( &$values_to_check, $value ) {
551 $values_to_check[] = is_array( $value ) ? FrmAppHelper::safe_implode( ' ', $value ) : $value;
552 }
553
554 /**
555 * Checks if IP is denied.
556 *
557 * @return bool
558 */
559 protected function check_ip() {
560 $ip = FrmAppHelper::get_ip_address();
561
562 if ( $this->is_allowed_ip( $ip ) ) {
563 return false;
564 }
565
566 $denylist_ips = $this->get_denylist_ips();
567
568 if ( ! empty( $denylist_ips['custom'] ) && $this->ip_matches_array( $ip, $denylist_ips['custom'] ) ) {
569 return true;
570 }
571
572 if ( empty( $denylist_ips['files'] ) || ! is_array( $denylist_ips['files'] ) ) {
573 return false;
574 }
575
576 foreach ( $denylist_ips['files'] as $file ) {
577 if ( ! file_exists( $file ) ) {
578 continue;
579 }
580
581 $is_spam = $this->read_lines_and_check(
582 $file,
583 array( $this, 'single_line_check_ip' ),
584 compact( 'ip' )
585 );
586
587 if ( $is_spam ) {
588 return true;
589 }
590 }
591
592 return false;
593 }
594
595 /**
596 * Reads lines in file and do the check.
597 *
598 * @param string $file_path File path.
599 * @param callable $callback Check callback.
600 * @param array $callback_args Callback args.
601 *
602 * @return bool
603 */
604 protected function read_lines_and_check( $file_path, $callback, $callback_args = array() ) {
605 if ( ! is_callable( $callback ) ) {
606 return false;
607 }
608
609 $fp = @fopen( $file_path, 'r' );
610
611 if ( ! $fp ) {
612 return false;
613 }
614
615 while ( ( $line = fgets( $fp ) ) !== false ) {
616 $line = trim( $line );
617
618 if ( $line === '' ) {
619 continue;
620 }
621
622 $is_spam = $callback( $line, $callback_args );
623
624 if ( ! $is_spam ) {
625 continue;
626 }
627
628 if ( is_array( $callback ) && isset( $callback[1] ) && 'single_line_check_values' === $callback[1] ) {
629 self::add_spam_keyword_to_option( $line );
630 }
631
632 fclose( $fp );
633 return true;
634 }
635
636 fclose( $fp );
637 return false;
638 }
639
640 /**
641 * Checks if the given IP is allowed.
642 *
643 * @param string $ip IP address.
644 *
645 * @return bool
646 */
647 protected function is_allowed_ip( $ip ) {
648 return $this->ip_matches_array( $ip, FrmAntiSpamController::get_allowed_ips() );
649 }
650
651 /**
652 * @param string $line
653 * @param array $args
654 *
655 * @return bool
656 */
657 protected function single_line_check_ip( $line, $args ) {
658 return $this->ip_matches( $args['ip'], $line );
659 }
660
661 /**
662 * Checks if the given IP address matches the IP address with CIDR format.
663 *
664 * @param string $ip IP address.
665 * @param string $cidr_ip IP address with CIDR format (x.x.x.x/24).
666 *
667 * @return bool
668 */
669 protected function ip_matches( $ip, $cidr_ip ) {
670 $cidr_parts = explode( '/', $cidr_ip );
671
672 // If the second IP doesn't have CIDR format, just use equals comparison.
673 if ( 1 === count( $cidr_parts ) ) {
674 return $ip === $cidr_ip;
675 }
676
677 if ( str_starts_with( $ip . '/', $cidr_ip ) ) {
678 // 1.1.1.1 and 1.1.1.1/24 matches.
679 return true;
680 }
681
682 // Validate IP address format - only IPv4 is supported in the CIDR check.
683 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
684 return false;
685 }
686
687 list( $net, $mask ) = explode( '/', $cidr_ip );
688
689 $ip_net = ip2long( $net );
690 $ip_mask = ~( ( 1 << ( 32 - intval( $mask ) ) ) - 1 ); // phpcs:ignore SlevomatCodingStandard.PHP.UselessParentheses.UselessParentheses
691
692 $ip_ip = ip2long( $ip );
693
694 return ( $ip_ip & $ip_mask ) === ( $ip_net & $ip_mask );
695 }
696
697 /**
698 * Checks if the given IP matches an IP in the array.
699 *
700 * @param string $ip The IP address.
701 * @param string[] $ip_array Array of IP addresses.
702 *
703 * @return bool
704 */
705 protected function ip_matches_array( $ip, $ip_array ) {
706 foreach ( $ip_array as $cidr_ip ) {
707 if ( $this->ip_matches( $ip, $cidr_ip ) ) {
708 return true;
709 }
710 }
711 return false;
712 }
713
714 protected function get_spam_message() {
715 return __( 'Your entry appears to be blocked spam!', 'formidable' );
716 }
717
718 /**
719 * @param string $keyword
720 *
721 * @return void
722 */
723 private function add_spam_keyword_to_option( $keyword ) {
724 $transient_name = 'frm_recent_spam_detected';
725 $transient = get_transient( $transient_name );
726
727 if ( ! is_array( $transient ) ) {
728 $transient = array();
729 }
730
731 if ( in_array( $keyword, $transient, true ) ) {
732 return;
733 }
734
735 $transient[] = $keyword;
736 set_transient( $transient_name, $transient, DAY_IN_SECONDS );
737 }
738 }
739