'', ); $data = get_option( 'accua_forms_spam_data', array() ); if ( ! is_array( $data ) ) { $data = array(); } $data = $data + $defaults; // The list is stored as text, but an option written by hand, by a // migration or by an older shape can hold an array. Casting that to // string raises "Array to string conversion" in every caller, so fold it // back into lines instead of losing it. if ( is_array( $data['email_blocklist'] ) ) { $entries = array(); foreach ( $data['email_blocklist'] as $entry ) { if ( is_scalar( $entry ) ) { $entries[] = (string) $entry; } } $data['email_blocklist'] = implode( "\n", $entries ); } elseif ( ! is_string( $data['email_blocklist'] ) ) { $data['email_blocklist'] = is_scalar( $data['email_blocklist'] ) ? (string) $data['email_blocklist'] : ''; } return $data; } /** * Clean up a single blocklist entry. * * Accepts what people actually paste - "Spam Bot ", * "mailto:bot@example.net" - and lowercases everything, since addresses are * matched case-insensitively. * * Returns '' for anything unusable, which includes the entries that would * silently mark every submission carrying an email address as spam: an entry * made only of wildcards and '@' ('*', '*@*', '?') is dropped rather than * stored. Blocking everything stays expressible per domain ('*@example.com'), * never by accident. * * @since 2.3.1 * @param string $entry One entry as typed. * @return string The normalized entry, or '' if it cannot be used. */ function accua_forms_normalize_blocklist_entry( $entry ) { $entry = trim( (string) $entry ); if ( '' === $entry ) { return ''; } // "Display Name
" - keep the address. The trim() after it catches // an unbalanced bracket, which the pattern above does not match. if ( preg_match( '/<([^>]*)>/', $entry, $matches ) ) { $entry = $matches[1]; } $entry = trim( $entry, " \t<>" ); $entry = preg_replace( '/^mailto:/i', '', $entry ); $entry = strtolower( trim( $entry ) ); // Characters no address and no pattern over one can contain. Whitespace, // commas and semicolons are already gone (they separate entries), so what // is left here is a typo that could never match anything. if ( '' === $entry || preg_match( '/[<>"\'\\\\()\[\]:;,\s]/', $entry ) ) { return ''; } // A run of consecutive '*' means exactly what one '*' means, and each one // becomes a '.*' in the match regex: 20 of them in a row turn a failed // match into minutes of backtracking. Collapsing is not a restriction, // the pattern keeps matching precisely what it did. $entry = preg_replace( '/\*{2,}/', '*', $entry ); // At least one character that is not a wildcard or the '@' itself. if ( ! preg_match( '/[^*?@]/', $entry ) ) { return ''; } // An entry with no '@' is shorthand for a whole domain, so it has to look // like one: a dot, or a wildcard. Without this check a stray word from a // pasted line would become an entry of its own and be read as a domain - // "Spam Bot " splits on its spaces, and "spam" would then // match every address at a host called spam. if ( false === strpos( $entry, '@' ) && false === strpos( $entry, '.' ) && ! preg_match( '/[*?]/', $entry ) ) { return ''; } return $entry; } /** * Normalize a raw blocklist textarea into the text that gets stored. * * Entries may be separated by newlines, commas, semicolons or spaces; lines * starting with '#' are comments and are kept verbatim (the list doubles as * the note of why an address is on it). Duplicates are dropped. * * @since 2.3.1 * @param string $raw The submitted textarea content. * @return string One entry (or comment) per line. */ function accua_forms_filter_email_blocklist( $raw ) { $lines = array(); $seen = array(); foreach ( preg_split( '/[\r\n]+/', (string) $raw ) as $line ) { $line = trim( $line ); if ( '' === $line ) { continue; } if ( 0 === strpos( $line, '#' ) ) { $lines[] = $line; continue; } // "Display Name
" has to be unwrapped before the line is split // on its spaces, or the display name becomes entries of its own. if ( preg_match_all( '/<([^>]*)>/', $line, $matches ) ) { $line = implode( ' ', $matches[1] ); } foreach ( preg_split( '/[\s,;]+/', $line ) as $entry ) { $entry = accua_forms_normalize_blocklist_entry( $entry ); if ( '' === $entry || isset( $seen[ $entry ] ) ) { continue; } $seen[ $entry ] = true; $lines[] = $entry; } } return implode( "\n", $lines ); } /** * The blocklist as a list of patterns, without comments or blank lines. * * @since 2.3.1 * @return array List of patterns. */ function accua_forms_get_email_blocklist() { $settings = accua_forms_spam_settings(); $patterns = array(); foreach ( preg_split( '/[\r\n]+/', (string) $settings['email_blocklist'] ) as $line ) { $line = trim( $line ); if ( '' === $line || 0 === strpos( $line, '#' ) ) { continue; } $patterns[] = $line; } /** * Filter the email blocklist patterns. * * Lets code add addresses the settings page does not hold - a list shared * across a network, an external service - without touching the option. * * @since 2.3.1 * @param array $patterns Patterns read from the settings page. */ $patterns = apply_filters( 'accua_forms_email_blocklist', $patterns ); if ( ! is_array( $patterns ) ) { return array(); } $clean = array(); foreach ( $patterns as $pattern ) { if ( is_scalar( $pattern ) && '' !== trim( (string) $pattern ) ) { $clean[] = trim( (string) $pattern ); } } return $clean; } /** * Does an email address match one blocklist pattern? * * Matching is case-insensitive over the whole address, with '*' standing for * any run of characters and '?' for exactly one. A pattern with no '@' * ("spammydomain.net") and one that starts with it ("@spammydomain.net") both * read as "*@spammydomain.net" - the common case, spelled the way people write * it. Subdomains are not implied: "*@*.spammydomain.net" is how you catch them. * * @since 2.3.1 * @param string $email The address to test. * @param string $pattern One blocklist entry. * @return bool */ function accua_forms_email_matches_blocklist_pattern( $email, $pattern ) { $email = strtolower( trim( (string) $email ) ); $pattern = strtolower( trim( (string) $pattern ) ); if ( '' === $email || '' === $pattern ) { return false; } // Patterns from the accua_forms_email_blocklist filter, and any stored by // a version before this one, never passed through the normalizer: collapse // the wildcard runs here too. Semantics are unchanged, there is simply // less to walk. $pattern = preg_replace( '/\*{2,}/', '*', $pattern ); if ( false === strpos( $pattern, '@' ) ) { $pattern = '*@' . $pattern; } elseif ( 0 === strpos( $pattern, '@' ) ) { $pattern = '*' . $pattern; } return accua_forms_glob_matches( $pattern, $email ); } /** * Glob matching without a regex. * * The obvious implementation turns the pattern into a regex ('*' to '.*', * '?' to '.') and calls preg_match(). That is what this did until 2.3.2, and * it fails quietly: every '*' becomes a backtracking point, so a pattern as * ordinary as '*info*@*.example.com' exhausts PCRE's backtrack limit against a * long address that does not match. preg_match() then returns false, not 0, * and the caller's '1 === preg_match(...)' reads that as "no match" - the * blocklist entry silently stops blocking, and only for some addresses. * * This is the standard linear glob walk instead: advance through both strings, * remember the last '*' and the position it was matched at, and on a mismatch * return to it having consumed one more character. No recursion, no * backtracking stack, no limit to exhaust. * * Comparison is byte-wise, so '?' matches one byte exactly as the regex '.' * did without the /u modifier. Both arguments are already lowercased. * * @since 2.3.2 * @param string $pattern Glob pattern ('*' any run, '?' exactly one). * @param string $subject The string to test. * @return bool */ function accua_forms_glob_matches( $pattern, $subject ) { $plen = strlen( $pattern ); $slen = strlen( $subject ); $p = 0; $s = 0; $star = -1; $mark = 0; while ( $s < $slen ) { if ( $p < $plen && ( '?' === $pattern[ $p ] || $pattern[ $p ] === $subject[ $s ] ) ) { $p++; $s++; } elseif ( $p < $plen && '*' === $pattern[ $p ] ) { $star = $p; $mark = $s; $p++; } elseif ( $star >= 0 ) { $p = $star + 1; $mark++; $s = $mark; } else { return false; } } while ( $p < $plen && '*' === $pattern[ $p ] ) { $p++; } return $p === $plen; } /** * Is this email address on the blocklist? * * @since 2.3.1 * @param string $email The address to test. * @return bool */ function accua_forms_email_is_blocklisted( $email ) { $email = trim( (string) $email ); $blocked = false; if ( '' !== $email ) { foreach ( accua_forms_get_email_blocklist() as $pattern ) { if ( accua_forms_email_matches_blocklist_pattern( $email, $pattern ) ) { $blocked = true; break; } } } /** * Filter whether an email address is treated as spam. * * @since 2.3.1 * @param bool $blocked Whether the blocklist matched. * @param string $email The address that was tested. */ return (bool) apply_filters( 'accua_forms_email_is_blocklisted', $blocked, $email ); } /** * The field types the blocklist reads. * * @since 2.3.1 * @return array Field type ids. */ function accua_forms_spam_email_field_types() { /** * Filter which field types are scanned against the email blocklist. * * @since 2.3.1 * @param array $types Field type ids. */ $types = apply_filters( 'accua_forms_spam_email_field_types', array( 'email', 'autoreply_email' ) ); return is_array( $types ) ? $types : array(); } /** * Does this submission carry a blocklisted address in one of its email fields? * * @since 2.3.1 * @param array $submitted_data Submitted values keyed by field instance id. * @param array $form_data The saved form data (needs 'fields'). * @return bool */ function accua_forms_submission_has_blocklisted_email( $submitted_data, $form_data ) { if ( ! is_array( $submitted_data ) || empty( $form_data['fields'] ) || ! is_array( $form_data['fields'] ) ) { return false; } // Nothing configured: never look at the submitted values at all. if ( ! accua_forms_get_email_blocklist() ) { return false; } $avail_fields = get_option( 'accua_forms_avail_fields', array() ); $email_types = accua_forms_spam_email_field_types(); foreach ( $submitted_data as $istance_id => $value ) { if ( empty( $form_data['fields'][ $istance_id ]['ref'] ) ) { continue; } $ref = $form_data['fields'][ $istance_id ]['ref']; if ( empty( $avail_fields[ $ref ]['type'] ) || ! in_array( $avail_fields[ $ref ]['type'], $email_types, true ) ) { continue; } foreach ( (array) $value as $single ) { if ( is_scalar( $single ) && accua_forms_email_is_blocklisted( $single ) ) { return true; } } } return false; }