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
← All changes | classes/models/FrmSpamCheckDenylist.php +207 -25 6.25 → trunk View file →
@@ -2,8 +2,9 @@
2 2 /**
3 3 * Spam check using denylist
4 4 *
5 5 * @since 6.21
6 + *
6 7 * @package Formidable
7 8 */
8 9
9 10 if ( ! defined( 'ABSPATH' ) ) {
@@ -15,12 +16,46 @@
15 16 const COMPARE_CONTAINS = '';
16 17
17 18 const COMPARE_EQUALS = 'equals';
18 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 + */
19 48 protected $posted_fields;
20 49
50 + /**
51 + * @var array
52 + */
21 53 protected $denylist;
22 54
55 + /**
56 + * @param array $values
57 + */
23 58 public function __construct( $values ) {
24 59 $this->maybe_add_form_id_to_values( $values );
25 60
26 61 parent::__construct( $values );
@@ -27,8 +62,11 @@
27 62
28 63 $this->denylist = $this->get_denylist_array();
29 64 }
30 65
66 + /**
67 + * @return array
68 + */
31 69 protected function get_posted_fields() {
32 70 if ( is_null( $this->posted_fields ) ) {
33 71 $this->posted_fields = FrmField::get_all_for_form( $this->values['form_id'] );
34 72 }
@@ -38,8 +76,10 @@
38 76 /**
39 77 * Maybe add form ID to values. In file name validation, only item_meta in $values.
40 78 *
41 79 * @param array $values Spam check values.
80 + *
81 + * @return void
42 82 */
43 83 protected function maybe_add_form_id_to_values( &$values ) {
44 84 if ( ! empty( $values['form_id'] ) || empty( $values['item_meta'] ) ) {
45 85 return;
@@ -46,8 +86,9 @@
46 86 }
47 87
48 88 $field_id = key( $values['item_meta'] );
49 89 $field = FrmField::getOne( $field_id );
90 +
50 91 if ( $field ) {
51 92 $values['form_id'] = $field->form_id;
52 93 }
53 94 }
@@ -79,9 +120,9 @@
79 120 'file' => FrmAppHelper::plugin_path() . '/denylist/domain-partial.txt',
80 121 ),
81 122 array(
82 123 'file' => FrmAppHelper::plugin_path() . '/denylist/splorp-wp-comment.txt',
83 - 'skip' => FrmAppHelper::current_user_can( 'frm_create_entries' ),
124 + 'skip' => current_user_can( 'frm_create_entries' ),
84 125 'skip_field_types' => array( 'file' ),
85 126 ),
86 127 array(
87 128 'words' => array(
@@ -99,8 +140,9 @@
99 140 ),
100 141 );
101 142
102 143 $custom_denylist = $this->get_words_from_setting( 'disallowed_words' );
144 +
103 145 if ( $custom_denylist ) {
104 146 $denylist_data['custom'] = array(
105 147 'words' => $custom_denylist,
106 148 );
@@ -138,13 +180,9 @@
138 180 *
139 181 * @return bool
140 182 */
141 183 public function check() {
142 - if ( $this->check_ip() ) {
143 - return true;
144 - }
145 -
146 - return $this->check_values();
184 + return $this->check_ip() ? true : $this->check_values();
147 185 }
148 186
149 187 /**
150 188 * Checks entry values.
@@ -166,8 +204,13 @@
166 204
167 205 $this->fill_default_denylist_data( $denylist );
168 206 $denylist['allowed_words'] = $allowed_words;
169 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 +
170 213 if ( ! empty( $denylist['words'] ) ) {
171 214 foreach ( $denylist['words'] as $word ) {
172 215 if ( $this->single_line_check_values( $word, $denylist ) ) {
173 216 self::add_spam_keyword_to_option( $word );
@@ -175,8 +218,9 @@
175 218 }
176 219 }
177 220 } elseif ( file_exists( $denylist['file'] ) ) {
178 221 $is_spam = $this->read_lines_and_check( $denylist['file'], array( $this, 'single_line_check_values' ), $denylist );
222 +
179 223 if ( $is_spam ) {
180 224 return true;
181 225 }
182 226 }
@@ -188,8 +232,10 @@
188 232 /**
189 233 * Fills default denylist data.
190 234 *
191 235 * @param array $denylist Denylist.
236 + *
237 + * @return void
192 238 */
193 239 protected function fill_default_denylist_data( &$denylist ) {
194 240 $denylist = wp_parse_args(
195 241 $denylist,
@@ -210,21 +256,118 @@
210 256
211 257 // Some field types should never be checked.
212 258 $denylist['skip_field_types'] = array_merge(
213 259 $denylist['skip_field_types'],
214 - array( 'password', 'captcha', 'signature', 'checkbox', 'radio', 'select' )
260 + array( 'password', 'captcha', 'signature', 'checkbox', 'radio', 'select', 'ranking' )
215 261 );
216 262 }
217 263
218 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 + /**
219 360 * Gets words from setting.
220 361 *
221 362 * @param string $setting_key Setting key.
363 + *
222 364 * @return array
223 365 */
224 366 protected function get_words_from_setting( $setting_key ) {
225 367 $frm_settings = FrmAppHelper::get_settings();
226 368 $words = $frm_settings->$setting_key ?? '';
369 +
227 370 if ( ! $words ) {
228 371 return array();
229 372 }
230 373
@@ -236,40 +379,47 @@
236 379 /**
237 380 * Checks the values against each single word.
238 381 *
239 382 * @param string $line Single line.
240 - * @param array $args Check args.
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 + *
241 386 * @return bool
242 387 */
243 388 protected function single_line_check_values( $line, $args ) {
244 389 $line = $this->convert_to_lowercase( $line );
390 +
245 391 // Do not check if this word is in the allowed words.
246 392 if ( ! empty( $args['allowed_words'] ) && in_array( $line, $args['allowed_words'], true ) ) {
247 393 return false;
248 394 }
249 395
250 - $values_to_check = $this->get_values_to_check( $args );
251 - if ( ! $values_to_check ) {
396 + if ( ! isset( $args['values_to_check'] ) && ! $this->add_values_to_check( $args ) ) {
252 397 // Nothing needs to be checked.
253 398 return false;
254 399 }
255 400
256 401 if ( ! empty( $args['is_regex'] ) ) {
257 - return preg_match( '/' . trim( $line, '/' ) . '/i', $this->convert_values_to_string( $values_to_check ) );
402 + return preg_match( '/' . trim( $line, '/' ) . '/i', $args['values_string'] );
258 403 }
259 404
260 405 if ( self::COMPARE_EQUALS === $args['compare'] ) {
261 - foreach ( $values_to_check as $value ) {
406 + foreach ( $args['values_to_check'] as $value ) {
262 407 $value = $this->convert_to_lowercase( $value );
408 +
263 409 if ( $line === $value ) {
264 410 return true;
265 411 }
266 412 }
413 +
267 414 return false;
268 415 }
269 416
270 - $values_str = strtolower( $this->convert_values_to_string( $values_to_check ) );
271 - return strpos( $values_str, $line ) !== false;
417 + if ( $this->line_is_ruled_out( $line, $args ) ) {
418 + return false;
419 + }
420 +
421 + return str_contains( $args['values_string_lower'], $line );
272 422 }
273 423
274 424 /**
275 425 * Converts values to string to check.
@@ -274,8 +424,9 @@
274 424 /**
275 425 * Converts values to string to check.
276 426 *
277 427 * @param array $values Values array.
428 + *
278 429 * @return string
279 430 */
280 431 protected function convert_values_to_string( $values ) {
281 432 // Unslash the forward slashes so strings like /joomla/ are not stuck as \/joomla\/.
@@ -285,8 +436,9 @@
285 436 /**
286 437 * Converts string to lowercase.
287 438 *
288 439 * @param string $str String.
440 + *
289 441 * @return string
290 442 */
291 443 protected function convert_to_lowercase( $str ) {
292 444 return strtolower( $str );
@@ -308,10 +460,12 @@
308 460 return false;
309 461 }
310 462
311 463 $field_ids_to_check = array();
464 +
312 465 foreach ( $this->get_posted_fields() as $field ) {
313 466 $field_type = FrmField::get_field_type( $field );
467 +
314 468 if ( in_array( $field_type, $skip_field_types, true ) ) {
315 469 continue;
316 470 }
317 471
@@ -328,12 +482,14 @@
328 482 /**
329 483 * Gets values to check.
330 484 *
331 485 * @param array $denylist Single denylist data.
486 + *
332 487 * @return array|false Return `false` if no values need to check, or return array of values.
333 488 */
334 489 protected function get_values_to_check( $denylist ) {
335 490 $field_ids_to_check = $this->get_field_ids_to_check( $denylist );
491 +
336 492 if ( array() === $field_ids_to_check ) {
337 493 // No values need to check.
338 494 return false;
339 495 }
@@ -338,8 +494,9 @@
338 494 return false;
339 495 }
340 496
341 497 $values_to_check = array();
498 +
342 499 foreach ( $this->values['item_meta'] as $key => $value ) {
343 500 if ( is_array( $value ) && isset( $value['form'] ) ) {
344 501 // This is a repeater value, loop through sub values.
345 502 unset( $value['form'] );
@@ -362,9 +519,9 @@
362 519 }
363 520 }//end foreach
364 521
365 522 if ( isset( $denylist['extract_value'] ) && is_callable( $denylist['extract_value'] ) ) {
366 - $values_to_check = call_user_func( $denylist['extract_value'], $values_to_check, $denylist );
523 + return call_user_func( $denylist['extract_value'], $values_to_check, $denylist );
367 524 }
368 525
369 526 return $values_to_check;
370 527 }
@@ -371,10 +528,11 @@
371 528
372 529 /**
373 530 * Checks if should check the value of the given field ID.
374 531 *
375 - * @param int $field_id Field ID.
376 - * @param int[] $field_ids_to_check Field IDs to check.
532 + * @param int $field_id Field ID.
533 + * @param false|int[] $field_ids_to_check Field IDs to check.
534 + *
377 535 * @return bool
378 536 */
379 537 protected function should_check_this_field( $field_id, $field_ids_to_check ) {
380 538 // Should check this field if no field types is specific or this field ID is in the field IDs to check array.
@@ -385,11 +543,13 @@
385 543 * Adds the value to values to check array.
386 544 *
387 545 * @param array $values_to_check Values to check array.
388 546 * @param mixed $value The value.
547 + *
548 + * @return void
389 549 */
390 550 protected function add_to_values_to_check( &$values_to_check, $value ) {
391 - $values_to_check[] = is_array( $value ) ? implode( ' ', $value ) : $value;
551 + $values_to_check[] = is_array( $value ) ? FrmAppHelper::safe_implode( ' ', $value ) : $value;
392 552 }
393 553
394 554 /**
395 555 * Checks if IP is denied.
@@ -397,8 +557,9 @@
397 557 * @return bool
398 558 */
399 559 protected function check_ip() {
400 560 $ip = FrmAppHelper::get_ip_address();
561 +
401 562 if ( $this->is_allowed_ip( $ip ) ) {
402 563 return false;
403 564 }
404 565
@@ -436,8 +597,9 @@
436 597 *
437 598 * @param string $file_path File path.
438 599 * @param callable $callback Check callback.
439 600 * @param array $callback_args Callback args.
601 + *
440 602 * @return bool
441 603 */
442 604 protected function read_lines_and_check( $file_path, $callback, $callback_args = array() ) {
443 605 if ( ! is_callable( $callback ) ) {
@@ -444,8 +606,9 @@
444 606 return false;
445 607 }
446 608
447 609 $fp = @fopen( $file_path, 'r' );
610 +
448 611 if ( ! $fp ) {
449 612 return false;
450 613 }
451 614
@@ -450,21 +613,25 @@
450 613 }
451 614
452 615 while ( ( $line = fgets( $fp ) ) !== false ) {
453 616 $line = trim( $line );
617 +
454 618 if ( $line === '' ) {
455 619 continue;
456 620 }
457 621
458 622 $is_spam = $callback( $line, $callback_args );
459 - if ( $is_spam ) {
460 - if ( is_array( $callback ) && isset( $callback[1] ) && 'single_line_check_values' === $callback[1] ) {
461 - self::add_spam_keyword_to_option( $line );
462 - }
463 623
464 - fclose( $fp );
465 - return true;
624 + if ( ! $is_spam ) {
625 + continue;
466 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;
467 634 }
468 635
469 636 fclose( $fp );
470 637 return false;
@@ -473,8 +640,9 @@
473 640 /**
474 641 * Checks if the given IP is allowed.
475 642 *
476 643 * @param string $ip IP address.
644 + *
477 645 * @return bool
478 646 */
479 647 protected function is_allowed_ip( $ip ) {
480 648 return $this->ip_matches_array( $ip, FrmAntiSpamController::get_allowed_ips() );
@@ -479,8 +647,14 @@
479 647 protected function is_allowed_ip( $ip ) {
480 648 return $this->ip_matches_array( $ip, FrmAntiSpamController::get_allowed_ips() );
481 649 }
482 650
651 + /**
652 + * @param string $line
653 + * @param array $args
654 + *
655 + * @return bool
656 + */
483 657 protected function single_line_check_ip( $line, $args ) {
484 658 return $this->ip_matches( $args['ip'], $line );
485 659 }
486 660
@@ -488,8 +662,9 @@
488 662 * Checks if the given IP address matches the IP address with CIDR format.
489 663 *
490 664 * @param string $ip IP address.
491 665 * @param string $cidr_ip IP address with CIDR format (x.x.x.x/24).
666 + *
492 667 * @return bool
493 668 */
494 669 protected function ip_matches( $ip, $cidr_ip ) {
495 670 $cidr_parts = explode( '/', $cidr_ip );
@@ -498,9 +673,9 @@
498 673 if ( 1 === count( $cidr_parts ) ) {
499 674 return $ip === $cidr_ip;
500 675 }
501 676
502 - if ( 0 === strpos( $ip . '/', $cidr_ip ) ) {
677 + if ( str_starts_with( $ip . '/', $cidr_ip ) ) {
503 678 // 1.1.1.1 and 1.1.1.1/24 matches.
504 679 return true;
505 680 }
506 681
@@ -523,8 +698,9 @@
523 698 * Checks if the given IP matches an IP in the array.
524 699 *
525 700 * @param string $ip The IP address.
526 701 * @param string[] $ip_array Array of IP addresses.
702 + *
527 703 * @return bool
528 704 */
529 705 protected function ip_matches_array( $ip, $ip_array ) {
530 706 foreach ( $ip_array as $cidr_ip ) {
@@ -538,11 +714,17 @@
538 714 protected function get_spam_message() {
539 715 return __( 'Your entry appears to be blocked spam!', 'formidable' );
540 716 }
541 717
718 + /**
719 + * @param string $keyword
720 + *
721 + * @return void
722 + */
542 723 private function add_spam_keyword_to_option( $keyword ) {
543 724 $transient_name = 'frm_recent_spam_detected';
544 725 $transient = get_transient( $transient_name );
726 +
545 727 if ( ! is_array( $transient ) ) {
546 728 $transient = array();
547 729 }
548 730