PluginProbe
Contact Forms by Cimatti / trunk
Contact Forms by Cimatti vtrunk
2.3.6 2.3.5 2.3.0 2.2.32 2.2.4 2.2.0 2.1.2 2.1.1 trunk 1.0 1.1 1.2 1.2.1 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 All 62 releases
contact-forms / includes / spam-blocklist.php

spam-blocklist.php in Contact Forms by Cimatti trunk, at includes/spam-blocklist.php

388 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Auto mark as spam - email address blocklist.
4 *
5 * A site-wide list of email addresses and wildcard patterns. A submission that
6 * carries one of them in an email field is classified as spam by the
7 * submission handler whatever the captcha of that form decided: a captcha
8 * judges how a form was filled in, not who filled it in, so a known spammer
9 * who solves the challenge correctly still has to be caught.
10 *
11 * The list is stored as the text the administrator typed (one entry per line,
12 * '#' comment lines kept) rather than as an array, so the settings textarea can
13 * round-trip it unchanged and the order and the comments survive a save.
14 *
15 * Deliberately hook-free - plain functions only, like includes/data-deletion.php
16 * - and cheap when nothing is configured: the scan returns before it looks at a
17 * single submitted value if the list is empty.
18 *
19 * @package Contact Forms
20 * @since 2.3.1
21 */
22
23 if ( ! defined( 'ABSPATH' ) ) exit;
24
25 /**
26 * The stored spam settings, with every key guaranteed present.
27 *
28 * @since 2.3.1
29 * @return array The settings, with at least the 'email_blocklist' key.
30 */
31 function accua_forms_spam_settings() {
32 $defaults = array(
33 'email_blocklist' => '',
34 );
35 $data = get_option( 'accua_forms_spam_data', array() );
36 if ( ! is_array( $data ) ) {
37 $data = array();
38 }
39 $data = $data + $defaults;
40
41 // The list is stored as text, but an option written by hand, by a
42 // migration or by an older shape can hold an array. Casting that to
43 // string raises "Array to string conversion" in every caller, so fold it
44 // back into lines instead of losing it.
45 if ( is_array( $data['email_blocklist'] ) ) {
46 $entries = array();
47 foreach ( $data['email_blocklist'] as $entry ) {
48 if ( is_scalar( $entry ) ) {
49 $entries[] = (string) $entry;
50 }
51 }
52 $data['email_blocklist'] = implode( "\n", $entries );
53 } elseif ( ! is_string( $data['email_blocklist'] ) ) {
54 $data['email_blocklist'] = is_scalar( $data['email_blocklist'] ) ? (string) $data['email_blocklist'] : '';
55 }
56
57 return $data;
58 }
59
60 /**
61 * Clean up a single blocklist entry.
62 *
63 * Accepts what people actually paste - "Spam Bot <bot@example.net>",
64 * "mailto:bot@example.net" - and lowercases everything, since addresses are
65 * matched case-insensitively.
66 *
67 * Returns '' for anything unusable, which includes the entries that would
68 * silently mark every submission carrying an email address as spam: an entry
69 * made only of wildcards and '@' ('*', '*@*', '?') is dropped rather than
70 * stored. Blocking everything stays expressible per domain ('*@example.com'),
71 * never by accident.
72 *
73 * @since 2.3.1
74 * @param string $entry One entry as typed.
75 * @return string The normalized entry, or '' if it cannot be used.
76 */
77 function accua_forms_normalize_blocklist_entry( $entry ) {
78 $entry = trim( (string) $entry );
79 if ( '' === $entry ) {
80 return '';
81 }
82
83 // "Display Name <address>" - keep the address. The trim() after it catches
84 // an unbalanced bracket, which the pattern above does not match.
85 if ( preg_match( '/<([^>]*)>/', $entry, $matches ) ) {
86 $entry = $matches[1];
87 }
88 $entry = trim( $entry, " \t<>" );
89 $entry = preg_replace( '/^mailto:/i', '', $entry );
90 $entry = strtolower( trim( $entry ) );
91
92 // Characters no address and no pattern over one can contain. Whitespace,
93 // commas and semicolons are already gone (they separate entries), so what
94 // is left here is a typo that could never match anything.
95 if ( '' === $entry || preg_match( '/[<>"\'\\\\()\[\]:;,\s]/', $entry ) ) {
96 return '';
97 }
98
99 // A run of consecutive '*' means exactly what one '*' means, and each one
100 // becomes a '.*' in the match regex: 20 of them in a row turn a failed
101 // match into minutes of backtracking. Collapsing is not a restriction,
102 // the pattern keeps matching precisely what it did.
103 $entry = preg_replace( '/\*{2,}/', '*', $entry );
104
105 // At least one character that is not a wildcard or the '@' itself.
106 if ( ! preg_match( '/[^*?@]/', $entry ) ) {
107 return '';
108 }
109
110 // An entry with no '@' is shorthand for a whole domain, so it has to look
111 // like one: a dot, or a wildcard. Without this check a stray word from a
112 // pasted line would become an entry of its own and be read as a domain -
113 // "Spam Bot <bot@example.net>" splits on its spaces, and "spam" would then
114 // match every address at a host called spam.
115 if ( false === strpos( $entry, '@' )
116 && false === strpos( $entry, '.' )
117 && ! preg_match( '/[*?]/', $entry ) ) {
118 return '';
119 }
120
121 return $entry;
122 }
123
124 /**
125 * Normalize a raw blocklist textarea into the text that gets stored.
126 *
127 * Entries may be separated by newlines, commas, semicolons or spaces; lines
128 * starting with '#' are comments and are kept verbatim (the list doubles as
129 * the note of why an address is on it). Duplicates are dropped.
130 *
131 * @since 2.3.1
132 * @param string $raw The submitted textarea content.
133 * @return string One entry (or comment) per line.
134 */
135 function accua_forms_filter_email_blocklist( $raw ) {
136 $lines = array();
137 $seen = array();
138
139 foreach ( preg_split( '/[\r\n]+/', (string) $raw ) as $line ) {
140 $line = trim( $line );
141 if ( '' === $line ) {
142 continue;
143 }
144 if ( 0 === strpos( $line, '#' ) ) {
145 $lines[] = $line;
146 continue;
147 }
148 // "Display Name <address>" has to be unwrapped before the line is split
149 // on its spaces, or the display name becomes entries of its own.
150 if ( preg_match_all( '/<([^>]*)>/', $line, $matches ) ) {
151 $line = implode( ' ', $matches[1] );
152 }
153 foreach ( preg_split( '/[\s,;]+/', $line ) as $entry ) {
154 $entry = accua_forms_normalize_blocklist_entry( $entry );
155 if ( '' === $entry || isset( $seen[ $entry ] ) ) {
156 continue;
157 }
158 $seen[ $entry ] = true;
159 $lines[] = $entry;
160 }
161 }
162
163 return implode( "\n", $lines );
164 }
165
166 /**
167 * The blocklist as a list of patterns, without comments or blank lines.
168 *
169 * @since 2.3.1
170 * @return array List of patterns.
171 */
172 function accua_forms_get_email_blocklist() {
173 $settings = accua_forms_spam_settings();
174 $patterns = array();
175
176 foreach ( preg_split( '/[\r\n]+/', (string) $settings['email_blocklist'] ) as $line ) {
177 $line = trim( $line );
178 if ( '' === $line || 0 === strpos( $line, '#' ) ) {
179 continue;
180 }
181 $patterns[] = $line;
182 }
183
184 /**
185 * Filter the email blocklist patterns.
186 *
187 * Lets code add addresses the settings page does not hold - a list shared
188 * across a network, an external service - without touching the option.
189 *
190 * @since 2.3.1
191 * @param array $patterns Patterns read from the settings page.
192 */
193 $patterns = apply_filters( 'accua_forms_email_blocklist', $patterns );
194
195 if ( ! is_array( $patterns ) ) {
196 return array();
197 }
198
199 $clean = array();
200 foreach ( $patterns as $pattern ) {
201 if ( is_scalar( $pattern ) && '' !== trim( (string) $pattern ) ) {
202 $clean[] = trim( (string) $pattern );
203 }
204 }
205 return $clean;
206 }
207
208 /**
209 * Does an email address match one blocklist pattern?
210 *
211 * Matching is case-insensitive over the whole address, with '*' standing for
212 * any run of characters and '?' for exactly one. A pattern with no '@'
213 * ("spammydomain.net") and one that starts with it ("@spammydomain.net") both
214 * read as "*@spammydomain.net" - the common case, spelled the way people write
215 * it. Subdomains are not implied: "*@*.spammydomain.net" is how you catch them.
216 *
217 * @since 2.3.1
218 * @param string $email The address to test.
219 * @param string $pattern One blocklist entry.
220 * @return bool
221 */
222 function accua_forms_email_matches_blocklist_pattern( $email, $pattern ) {
223 $email = strtolower( trim( (string) $email ) );
224 $pattern = strtolower( trim( (string) $pattern ) );
225
226 if ( '' === $email || '' === $pattern ) {
227 return false;
228 }
229
230 // Patterns from the accua_forms_email_blocklist filter, and any stored by
231 // a version before this one, never passed through the normalizer: collapse
232 // the wildcard runs here too. Semantics are unchanged, there is simply
233 // less to walk.
234 $pattern = preg_replace( '/\*{2,}/', '*', $pattern );
235
236 if ( false === strpos( $pattern, '@' ) ) {
237 $pattern = '*@' . $pattern;
238 } elseif ( 0 === strpos( $pattern, '@' ) ) {
239 $pattern = '*' . $pattern;
240 }
241
242 return accua_forms_glob_matches( $pattern, $email );
243 }
244
245 /**
246 * Glob matching without a regex.
247 *
248 * The obvious implementation turns the pattern into a regex ('*' to '.*',
249 * '?' to '.') and calls preg_match(). That is what this did until 2.3.2, and
250 * it fails quietly: every '*' becomes a backtracking point, so a pattern as
251 * ordinary as '*info*@*.example.com' exhausts PCRE's backtrack limit against a
252 * long address that does not match. preg_match() then returns false, not 0,
253 * and the caller's '1 === preg_match(...)' reads that as "no match" - the
254 * blocklist entry silently stops blocking, and only for some addresses.
255 *
256 * This is the standard linear glob walk instead: advance through both strings,
257 * remember the last '*' and the position it was matched at, and on a mismatch
258 * return to it having consumed one more character. No recursion, no
259 * backtracking stack, no limit to exhaust.
260 *
261 * Comparison is byte-wise, so '?' matches one byte exactly as the regex '.'
262 * did without the /u modifier. Both arguments are already lowercased.
263 *
264 * @since 2.3.2
265 * @param string $pattern Glob pattern ('*' any run, '?' exactly one).
266 * @param string $subject The string to test.
267 * @return bool
268 */
269 function accua_forms_glob_matches( $pattern, $subject ) {
270 $plen = strlen( $pattern );
271 $slen = strlen( $subject );
272 $p = 0;
273 $s = 0;
274 $star = -1;
275 $mark = 0;
276
277 while ( $s < $slen ) {
278 if ( $p < $plen && ( '?' === $pattern[ $p ] || $pattern[ $p ] === $subject[ $s ] ) ) {
279 $p++;
280 $s++;
281 } elseif ( $p < $plen && '*' === $pattern[ $p ] ) {
282 $star = $p;
283 $mark = $s;
284 $p++;
285 } elseif ( $star >= 0 ) {
286 $p = $star + 1;
287 $mark++;
288 $s = $mark;
289 } else {
290 return false;
291 }
292 }
293
294 while ( $p < $plen && '*' === $pattern[ $p ] ) {
295 $p++;
296 }
297
298 return $p === $plen;
299 }
300
301 /**
302 * Is this email address on the blocklist?
303 *
304 * @since 2.3.1
305 * @param string $email The address to test.
306 * @return bool
307 */
308 function accua_forms_email_is_blocklisted( $email ) {
309 $email = trim( (string) $email );
310 $blocked = false;
311
312 if ( '' !== $email ) {
313 foreach ( accua_forms_get_email_blocklist() as $pattern ) {
314 if ( accua_forms_email_matches_blocklist_pattern( $email, $pattern ) ) {
315 $blocked = true;
316 break;
317 }
318 }
319 }
320
321 /**
322 * Filter whether an email address is treated as spam.
323 *
324 * @since 2.3.1
325 * @param bool $blocked Whether the blocklist matched.
326 * @param string $email The address that was tested.
327 */
328 return (bool) apply_filters( 'accua_forms_email_is_blocklisted', $blocked, $email );
329 }
330
331 /**
332 * The field types the blocklist reads.
333 *
334 * @since 2.3.1
335 * @return array Field type ids.
336 */
337 function accua_forms_spam_email_field_types() {
338 /**
339 * Filter which field types are scanned against the email blocklist.
340 *
341 * @since 2.3.1
342 * @param array $types Field type ids.
343 */
344 $types = apply_filters( 'accua_forms_spam_email_field_types', array( 'email', 'autoreply_email' ) );
345
346 return is_array( $types ) ? $types : array();
347 }
348
349 /**
350 * Does this submission carry a blocklisted address in one of its email fields?
351 *
352 * @since 2.3.1
353 * @param array $submitted_data Submitted values keyed by field instance id.
354 * @param array $form_data The saved form data (needs 'fields').
355 * @return bool
356 */
357 function accua_forms_submission_has_blocklisted_email( $submitted_data, $form_data ) {
358 if ( ! is_array( $submitted_data ) || empty( $form_data['fields'] ) || ! is_array( $form_data['fields'] ) ) {
359 return false;
360 }
361
362 // Nothing configured: never look at the submitted values at all.
363 if ( ! accua_forms_get_email_blocklist() ) {
364 return false;
365 }
366
367 $avail_fields = get_option( 'accua_forms_avail_fields', array() );
368 $email_types = accua_forms_spam_email_field_types();
369
370 foreach ( $submitted_data as $istance_id => $value ) {
371 if ( empty( $form_data['fields'][ $istance_id ]['ref'] ) ) {
372 continue;
373 }
374 $ref = $form_data['fields'][ $istance_id ]['ref'];
375 if ( empty( $avail_fields[ $ref ]['type'] )
376 || ! in_array( $avail_fields[ $ref ]['type'], $email_types, true ) ) {
377 continue;
378 }
379 foreach ( (array) $value as $single ) {
380 if ( is_scalar( $single ) && accua_forms_email_is_blocklisted( $single ) ) {
381 return true;
382 }
383 }
384 }
385
386 return false;
387 }
388