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 +136 -9 6.34 → trunk View file →
@@ -17,8 +17,33 @@
17 17
18 18 const COMPARE_EQUALS = 'equals';
19 19
20 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 + /**
21 46 * @var array|null
22 47 */
23 48 protected $posted_fields;
24 49
@@ -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(
@@ -179,8 +204,13 @@
179 204
180 205 $this->fill_default_denylist_data( $denylist );
181 206 $denylist['allowed_words'] = $allowed_words;
182 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 +
183 213 if ( ! empty( $denylist['words'] ) ) {
184 214 foreach ( $denylist['words'] as $word ) {
185 215 if ( $this->single_line_check_values( $word, $denylist ) ) {
186 216 self::add_spam_keyword_to_option( $word );
@@ -231,8 +261,103 @@
231 261 );
232 262 }
233 263
234 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 + /**
235 360 * Gets words from setting.
236 361 *
237 362 * @param string $setting_key Setting key.
238 363 *
@@ -254,9 +379,10 @@
254 379 /**
255 380 * Checks the values against each single word.
256 381 *
257 382 * @param string $line Single line.
258 - * @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()}.
259 385 *
260 386 * @return bool
261 387 */
262 388 protected function single_line_check_values( $line, $args ) {
@@ -266,21 +392,19 @@
266 392 if ( ! empty( $args['allowed_words'] ) && in_array( $line, $args['allowed_words'], true ) ) {
267 393 return false;
268 394 }
269 395
270 - $values_to_check = $this->get_values_to_check( $args );
271 -
272 - if ( ! $values_to_check ) {
396 + if ( ! isset( $args['values_to_check'] ) && ! $this->add_values_to_check( $args ) ) {
273 397 // Nothing needs to be checked.
274 398 return false;
275 399 }
276 400
277 401 if ( ! empty( $args['is_regex'] ) ) {
278 - return preg_match( '/' . trim( $line, '/' ) . '/i', $this->convert_values_to_string( $values_to_check ) );
402 + return preg_match( '/' . trim( $line, '/' ) . '/i', $args['values_string'] );
279 403 }
280 404
281 405 if ( self::COMPARE_EQUALS === $args['compare'] ) {
282 - foreach ( $values_to_check as $value ) {
406 + foreach ( $args['values_to_check'] as $value ) {
283 407 $value = $this->convert_to_lowercase( $value );
284 408
285 409 if ( $line === $value ) {
286 410 return true;
@@ -289,10 +413,13 @@
289 413
290 414 return false;
291 415 }
292 416
293 - $values_str = strtolower( $this->convert_values_to_string( $values_to_check ) );
294 - return str_contains( $values_str, $line );
417 + if ( $this->line_is_ruled_out( $line, $args ) ) {
418 + return false;
419 + }
420 +
421 + return str_contains( $args['values_string_lower'], $line );
295 422 }
296 423
297 424 /**
298 425 * Converts values to string to check.