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 +153 -27 6.26 → trunk View file →
@@ -17,10 +17,35 @@
17 17
18 18 const COMPARE_EQUALS = 'equals';
19 19
20 20 /**
21 - * @var array
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
22 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 + */
23 48 protected $posted_fields;
24 49
25 50 /**
26 51 * @var array
@@ -95,9 +120,9 @@
95 120 'file' => FrmAppHelper::plugin_path() . '/denylist/domain-partial.txt',
96 121 ),
97 122 array(
98 123 'file' => FrmAppHelper::plugin_path() . '/denylist/splorp-wp-comment.txt',
99 - 'skip' => FrmAppHelper::current_user_can( 'frm_create_entries' ),
124 + 'skip' => current_user_can( 'frm_create_entries' ),
100 125 'skip_field_types' => array( 'file' ),
101 126 ),
102 127 array(
103 128 'words' => array(
@@ -155,13 +180,9 @@
155 180 *
156 181 * @return bool
157 182 */
158 183 public function check() {
159 - if ( $this->check_ip() ) {
160 - return true;
161 - }
162 -
163 - return $this->check_values();
184 + return $this->check_ip() ? true : $this->check_values();
164 185 }
165 186
166 187 /**
167 188 * Checks entry values.
@@ -183,8 +204,13 @@
183 204
184 205 $this->fill_default_denylist_data( $denylist );
185 206 $denylist['allowed_words'] = $allowed_words;
186 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 +
187 213 if ( ! empty( $denylist['words'] ) ) {
188 214 foreach ( $denylist['words'] as $word ) {
189 215 if ( $this->single_line_check_values( $word, $denylist ) ) {
190 216 self::add_spam_keyword_to_option( $word );
@@ -230,13 +256,108 @@
230 256
231 257 // Some field types should never be checked.
232 258 $denylist['skip_field_types'] = array_merge(
233 259 $denylist['skip_field_types'],
234 - array( 'password', 'captcha', 'signature', 'checkbox', 'radio', 'select' )
260 + array( 'password', 'captcha', 'signature', 'checkbox', 'radio', 'select', 'ranking' )
235 261 );
236 262 }
237 263
238 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 + /**
239 360 * Gets words from setting.
240 361 *
241 362 * @param string $setting_key Setting key.
242 363 *
@@ -258,9 +379,10 @@
258 379 /**
259 380 * Checks the values against each single word.
260 381 *
261 382 * @param string $line Single line.
262 - * @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()}.
263 385 *
264 386 * @return bool
265 387 */
266 388 protected function single_line_check_values( $line, $args ) {
@@ -270,21 +392,19 @@
270 392 if ( ! empty( $args['allowed_words'] ) && in_array( $line, $args['allowed_words'], true ) ) {
271 393 return false;
272 394 }
273 395
274 - $values_to_check = $this->get_values_to_check( $args );
275 -
276 - if ( ! $values_to_check ) {
396 + if ( ! isset( $args['values_to_check'] ) && ! $this->add_values_to_check( $args ) ) {
277 397 // Nothing needs to be checked.
278 398 return false;
279 399 }
280 400
281 401 if ( ! empty( $args['is_regex'] ) ) {
282 - return preg_match( '/' . trim( $line, '/' ) . '/i', $this->convert_values_to_string( $values_to_check ) );
402 + return preg_match( '/' . trim( $line, '/' ) . '/i', $args['values_string'] );
283 403 }
284 404
285 405 if ( self::COMPARE_EQUALS === $args['compare'] ) {
286 - foreach ( $values_to_check as $value ) {
406 + foreach ( $args['values_to_check'] as $value ) {
287 407 $value = $this->convert_to_lowercase( $value );
288 408
289 409 if ( $line === $value ) {
290 410 return true;
@@ -289,13 +409,17 @@
289 409 if ( $line === $value ) {
290 410 return true;
291 411 }
292 412 }
413 +
293 414 return false;
294 415 }
295 416
296 - $values_str = strtolower( $this->convert_values_to_string( $values_to_check ) );
297 - 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 );
298 422 }
299 423
300 424 /**
301 425 * Converts values to string to check.
@@ -395,9 +519,9 @@
395 519 }
396 520 }//end foreach
397 521
398 522 if ( isset( $denylist['extract_value'] ) && is_callable( $denylist['extract_value'] ) ) {
399 - $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 );
400 524 }
401 525
402 526 return $values_to_check;
403 527 }
@@ -404,10 +528,10 @@
404 528
405 529 /**
406 530 * Checks if should check the value of the given field ID.
407 531 *
408 - * @param int $field_id Field ID.
409 - * @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.
410 534 *
411 535 * @return bool
412 536 */
413 537 protected function should_check_this_field( $field_id, $field_ids_to_check ) {
@@ -423,9 +547,9 @@
423 547 *
424 548 * @return void
425 549 */
426 550 protected function add_to_values_to_check( &$values_to_check, $value ) {
427 - $values_to_check[] = is_array( $value ) ? implode( ' ', $value ) : $value;
551 + $values_to_check[] = is_array( $value ) ? FrmAppHelper::safe_implode( ' ', $value ) : $value;
428 552 }
429 553
430 554 /**
431 555 * Checks if IP is denied.
@@ -496,16 +620,18 @@
496 620 }
497 621
498 622 $is_spam = $callback( $line, $callback_args );
499 623
500 - if ( $is_spam ) {
501 - if ( is_array( $callback ) && isset( $callback[1] ) && 'single_line_check_values' === $callback[1] ) {
502 - self::add_spam_keyword_to_option( $line );
503 - }
624 + if ( ! $is_spam ) {
625 + continue;
626 + }
504 627
505 - fclose( $fp );
506 - return true;
628 + if ( is_array( $callback ) && isset( $callback[1] ) && 'single_line_check_values' === $callback[1] ) {
629 + self::add_spam_keyword_to_option( $line );
507 630 }
631 +
632 + fclose( $fp );
633 + return true;
508 634 }
509 635
510 636 fclose( $fp );
511 637 return false;
@@ -547,9 +673,9 @@
547 673 if ( 1 === count( $cidr_parts ) ) {
548 674 return $ip === $cidr_ip;
549 675 }
550 676
551 - if ( 0 === strpos( $ip . '/', $cidr_ip ) ) {
677 + if ( str_starts_with( $ip . '/', $cidr_ip ) ) {
552 678 // 1.1.1.1 and 1.1.1.1/24 matches.
553 679 return true;
554 680 }
555 681