define_integrations(); // Stored data upgrades (runs once per data version). add_action( 'plugins_loaded', array( $this, 'maybe_upgrade' ), 5 ); // Frontend assets. add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'login_enqueue_scripts', array( $this, 'enqueue_assets' ) ); // Fresh tokens for pages served from a page cache. add_action( 'wp_ajax_accessnow_spam_eater_tokens', array( $this, 'ajax_tokens' ) ); add_action( 'wp_ajax_nopriv_accessnow_spam_eater_tokens', array( $this, 'ajax_tokens' ) ); // The visitor's own interaction token, asked for when they first touch the page. add_action( 'wp_ajax_accessnow_spam_eater_act', array( $this, 'ajax_act' ) ); add_action( 'wp_ajax_nopriv_accessnow_spam_eater_act', array( $this, 'ajax_act' ) ); // Admin. add_action( 'admin_menu', array( $this, 'add_settings_page' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_init', array( $this, 'add_privacy_policy_content' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); add_action( 'wp_ajax_accessnow_spam_eater_unblock_ip', array( $this, 'ajax_unblock_ip' ) ); add_action( 'admin_post_accessnow_spam_eater_clear_log', array( $this, 'handle_clear_log' ) ); add_action( 'update_option_accessnow_spam_eater_settings', array( $this, 'on_settings_updated' ), 10, 2 ); add_action( 'add_option_accessnow_spam_eater_settings', function ( $name, $value ) { $this->on_settings_updated( null, $value ); }, 10, 2 ); add_action( 'wp_ajax_accessnow_spam_eater_bot_test_result', array( $this, 'ajax_bot_test_result' ) ); // Dashboard widget and weekly summary. new Dashboard( $this ); // Register integration hooks after all plugins are loaded so detection is accurate. add_action( 'plugins_loaded', array( $this, 'register_hooks' ) ); } /** * Register only the hooks for integrations whose plugins are actually active. * Called on plugins_loaded so all plugin classes/constants/functions are available. */ public function register_hooks() { // ── WordPress Core (always active) ────────────────────────────── if ( $this->is_on( 'wp_comments' ) ) { add_filter( 'comment_form_defaults', array( $this, 'add_honeypot_to_comment_form' ) ); add_filter( 'preprocess_comment', array( $this, 'check_comment_honeypot' ) ); } if ( $this->is_on( 'wp_login' ) ) { add_action( 'login_form', array( $this, 'add_honeypot_to_wp_login' ) ); add_filter( 'authenticate', array( $this, 'check_wp_login_honeypot' ), 99, 3 ); } if ( $this->is_on( 'wp_registration' ) ) { add_action( 'register_form', array( $this, 'add_honeypot_to_wp_register' ) ); add_filter( 'registration_errors', array( $this, 'check_wp_register_honeypot' ), 10, 3 ); } if ( $this->is_on( 'wp_lost_password' ) ) { add_action( 'lostpassword_form', array( $this, 'add_honeypot_to_wp_lostpassword' ) ); // WooCommerce's My Account form runs the same lostpassword_post check, so it needs the fields too. add_action( 'woocommerce_lostpassword_form', array( $this, 'add_honeypot_to_wp_lostpassword' ) ); add_action( 'lostpassword_post', array( $this, 'check_wp_lostpassword_honeypot' ) ); } // ── WooCommerce ───────────────────────────────────────────────── if ( $this->is_on( 'woo_checkout' ) ) { add_action( 'woocommerce_after_order_notes', array( $this, 'add_honeypot_to_checkout' ) ); add_action( 'woocommerce_checkout_process', array( $this, 'check_checkout_honeypot' ) ); } if ( $this->is_on( 'woo_login' ) ) { add_action( 'woocommerce_login_form', array( $this, 'add_honeypot_to_woo_login' ) ); add_filter( 'woocommerce_process_login_errors', array( $this, 'check_woo_login_honeypot' ) ); } if ( $this->is_on( 'woo_register' ) ) { add_action( 'woocommerce_register_form', array( $this, 'add_honeypot_to_woo_register' ) ); add_filter( 'woocommerce_process_registration_errors', array( $this, 'check_woo_register_honeypot' ) ); } // ── Contact Form 7 ────────────────────────────────────────────── if ( $this->is_on( 'cf7' ) ) { add_filter( 'wpcf7_form_elements', array( $this, 'add_honeypot_to_cf7' ) ); add_filter( 'wpcf7_validate', array( $this, 'check_cf7_honeypot' ), 10, 2 ); } // ── Gravity Forms ─────────────────────────────────────────────── if ( $this->is_on( 'gravity_forms' ) ) { add_filter( 'gform_submit_button', array( $this, 'add_honeypot_to_gravity_forms' ), 10, 2 ); add_filter( 'gform_validation', array( $this, 'check_gravity_forms_honeypot' ) ); } // ── WPForms ───────────────────────────────────────────────────── if ( $this->is_on( 'wpforms' ) ) { add_action( 'wpforms_display_submit_before', array( $this, 'add_honeypot_to_wpforms' ) ); add_filter( 'wpforms_process_initial_errors', array( $this, 'check_wpforms_honeypot' ), 10, 2 ); } // ── Formidable Forms ──────────────────────────────────────────── if ( $this->is_on( 'formidable' ) ) { add_action( 'frm_entry_form', array( $this, 'add_honeypot_to_formidable' ) ); add_filter( 'frm_validate_entry', array( $this, 'check_formidable_honeypot' ), 10, 2 ); } // ── Fluent Forms ──────────────────────────────────────────────── if ( $this->is_on( 'fluent_forms' ) ) { add_action( 'fluentform_before_form_submit_button', array( $this, 'add_honeypot_to_fluent_forms' ) ); add_filter( 'fluentform_validation_errors', array( $this, 'check_fluent_forms_honeypot' ), 10, 4 ); } // ── bbPress ───────────────────────────────────────────────────── if ( $this->is_on( 'bbpress' ) ) { add_action( 'bbp_theme_before_reply_form_submit_wrapper', array( $this, 'add_honeypot_to_bbpress' ) ); add_action( 'bbp_theme_before_topic_form_submit_wrapper', array( $this, 'add_honeypot_to_bbpress' ) ); add_action( 'bbp_new_reply_pre_extras', array( $this, 'check_bbpress_honeypot' ) ); add_action( 'bbp_new_topic_pre_extras', array( $this, 'check_bbpress_honeypot' ) ); } // ── Elementor Pro Forms ───────────────────────────────────────── if ( $this->is_on( 'elementor' ) ) { add_action( 'elementor/widget/render_content', array( $this, 'add_honeypot_to_elementor_output' ), 10, 2 ); add_action( 'elementor_pro/forms/validation', array( $this, 'check_elementor_honeypot' ), 10, 2 ); } // ── Ninja Forms ───────────────────────────────────────────────── if ( $this->is_on( 'ninja_forms' ) ) { add_action( 'ninja_forms_display_after_fields', array( $this, 'add_honeypot_to_ninja_forms' ) ); add_filter( 'ninja_forms_submit_data', array( $this, 'check_ninja_forms_honeypot' ) ); } // ── Forminator ────────────────────────────────────────────────── if ( $this->is_on( 'forminator' ) ) { add_filter( 'forminator_render_button_markup', array( $this, 'add_honeypot_to_forminator' ), 10, 2 ); add_filter( 'forminator_custom_form_submit_errors', array( $this, 'check_forminator_honeypot' ), 10, 3 ); } // ── HTML Forms ────────────────────────────────────────────────── if ( $this->is_on( 'html_forms' ) ) { add_filter( 'hf_form_html', array( $this, 'add_honeypot_to_html_forms' ), 10, 2 ); add_filter( 'hf_validate_form', array( $this, 'check_html_forms_honeypot' ), 10, 3 ); } // ── BuddyPress / BuddyBoss / Youzify ─────────────────────────── if ( $this->is_on( 'buddypress' ) ) { add_action( 'bp_before_registration_submit_buttons', array( $this, 'add_honeypot_to_buddypress' ) ); add_action( 'bp_signup_validate', array( $this, 'check_buddypress_honeypot' ) ); } // ── Ultimate Member ───────────────────────────────────────────── if ( $this->is_on( 'ultimate_member' ) ) { add_action( 'um_after_register_fields', array( $this, 'add_honeypot_to_ultimate_member' ) ); add_action( 'um_submit_form_errors_hook__registration', array( $this, 'check_ultimate_member_honeypot' ) ); } // ── UsersWP ───────────────────────────────────────────────────── if ( $this->is_on( 'userswp' ) ) { add_action( 'uwp_template_fields', array( $this, 'add_honeypot_to_userswp' ), 10, 2 ); add_filter( 'uwp_validate_fields_before', array( $this, 'check_userswp_honeypot' ), 10, 3 ); } // ── MC4WP Mailchimp ───────────────────────────────────────────── if ( $this->is_on( 'mc4wp' ) ) { add_filter( 'mc4wp_form_content', array( $this, 'add_honeypot_to_mc4wp' ), 10, 3 ); add_filter( 'mc4wp_form_errors', array( $this, 'check_mc4wp_honeypot' ), 10, 2 ); } // ── Easy Digital Downloads ────────────────────────────────────── if ( $this->is_on( 'edd' ) ) { add_action( 'edd_purchase_form_before_submit', array( $this, 'add_honeypot_to_edd' ) ); add_action( 'edd_checkout_error_checks', array( $this, 'check_edd_honeypot' ), 10, 2 ); } // ── Bricks Builder ────────────────────────────────────────────── if ( $this->is_on( 'bricks' ) ) { add_filter( 'bricks/frontend/render_data', array( $this, 'add_honeypot_to_bricks' ), 10, 2 ); add_filter( 'bricks/form/validate', array( $this, 'check_bricks_honeypot' ), 10, 2 ); } // ── Beaver Builder ────────────────────────────────────────────── if ( $this->is_on( 'beaver' ) ) { add_filter( 'fl_builder_render_module_content', array( $this, 'add_honeypot_to_beaver' ), 10, 2 ); add_action( 'wp_ajax_nopriv_fl_builder_email', array( $this, 'check_beaver_honeypot' ), 1 ); add_action( 'wp_ajax_fl_builder_email', array( $this, 'check_beaver_honeypot' ), 1 ); } // ── QuForm ────────────────────────────────────────────────────── if ( $this->is_on( 'quform' ) ) { add_action( 'quform_pre_display', array( $this, 'add_honeypot_to_quform' ) ); add_filter( 'quform_post_validate', array( $this, 'check_quform_honeypot' ), 10, 2 ); } // ── Clean and Simple Contact Form ─────────────────────────────── if ( $this->is_on( 'cscf' ) ) { add_filter( 'do_shortcode_tag', array( $this, 'add_honeypot_to_cscf' ), 10, 2 ); // After the form's own Akismet check (10), which overwrites IsSpam. add_filter( 'cscf_spamfilter', array( $this, 'check_cscf_honeypot' ), 20 ); } // ── WooCommerce: block checkout, Pay for Order, Account details ─ if ( $this->is_on( 'woo_block_checkout' ) ) { if ( did_action( 'woocommerce_blocks_loaded' ) ) { $this->register_store_api_data(); } else { add_action( 'woocommerce_blocks_loaded', array( $this, 'register_store_api_data' ) ); } add_action( 'woocommerce_store_api_checkout_update_customer_from_request', array( $this, 'check_store_api_checkout' ), 10, 2 ); } if ( $this->is_on( 'woo_pay_order' ) ) { add_action( 'woocommerce_pay_order_before_submit', array( $this, 'add_honeypot_to_woo_pay_order' ) ); add_action( 'woocommerce_before_pay_action', array( $this, 'check_woo_pay_order' ) ); } if ( $this->is_on( 'woo_account_details' ) ) { add_action( 'woocommerce_edit_account_form', array( $this, 'add_honeypot_to_woo_account_details' ) ); add_action( 'woocommerce_save_account_details_errors', array( $this, 'check_woo_account_details' ) ); } // ── Jetpack Forms ─────────────────────────────────────────────── if ( $this->is_on( 'jetpack_forms' ) ) { add_filter( 'jetpack_contact_form_html', array( $this, 'add_honeypot_to_jetpack' ) ); add_filter( 'jetpack_contact_form_is_spam', array( $this, 'check_jetpack' ), 5 ); } // ── Kadence Blocks forms ──────────────────────────────────────── if ( $this->is_on( 'kadence_forms' ) ) { add_filter( 'render_block_kadence/form', array( $this, 'add_honeypot_to_kadence' ) ); add_filter( 'render_block_kadence/advanced-form', array( $this, 'add_honeypot_to_kadence' ) ); add_action( 'wp_ajax_kb_process_ajax_submit', array( $this, 'check_kadence_form' ), 5 ); add_action( 'wp_ajax_nopriv_kb_process_ajax_submit', array( $this, 'check_kadence_form' ), 5 ); add_filter( 'kadence_blocks_advanced_form_submission_reject', array( $this, 'check_kadence_advanced_form' ) ); add_filter( 'kadence_blocks_advanced_form_submission_reject_message', array( $this, 'kadence_reject_message' ) ); } // ── WS Form ───────────────────────────────────────────────────── if ( $this->is_on( 'ws_form' ) ) { add_filter( 'wsf_shortcode', array( $this, 'enqueue_for_ws_form' ) ); add_filter( 'wsf_submit_validate', array( $this, 'check_ws_form' ), 10, 2 ); } // ── JetFormBuilder ────────────────────────────────────────────── if ( $this->is_on( 'jetformbuilder' ) ) { add_filter( 'jet-form-builder/after-start-form', array( $this, 'add_honeypot_to_jetformbuilder' ) ); add_filter( 'jet-form-builder/request-handler/request', array( $this, 'check_jetformbuilder' ) ); } // ── SureForms ─────────────────────────────────────────────────── if ( $this->is_on( 'sureforms' ) ) { add_action( 'srfm_after_field_content', array( $this, 'add_honeypot_to_sureforms' ) ); add_filter( 'srfm_additional_restriction_check', array( $this, 'check_sureforms' ) ); add_filter( 'srfm_additional_restriction_message', array( $this, 'sureforms_message' ) ); } // ── Everest Forms ─────────────────────────────────────────────── if ( $this->is_on( 'everest_forms' ) ) { add_action( 'everest_forms_display_submit_before', array( $this, 'add_honeypot_to_everest' ) ); add_filter( 'everest_forms_process_initial_errors', array( $this, 'check_everest' ), 10, 2 ); } // ── MailPoet ──────────────────────────────────────────────────── if ( $this->is_on( 'mailpoet' ) ) { add_filter( 'mailpoet_form_widget_post_process', array( $this, 'add_honeypot_to_mailpoet' ) ); add_action( 'mailpoet_subscription_before_subscribe', array( $this, 'check_mailpoet' ) ); } // ── Brevo ─────────────────────────────────────────────────────── if ( $this->is_on( 'brevo' ) ) { add_filter( 'do_shortcode_tag', array( $this, 'add_honeypot_to_brevo' ), 10, 2 ); add_filter( 'widget_display_callback', array( $this, 'enqueue_for_brevo_widget' ), 10, 2 ); add_action( 'init', array( $this, 'check_brevo' ), 5 ); } // ── wpDiscuz (checked by the comment check, see check_comment_honeypot()) ── if ( $this->is_on( 'wpdiscuz' ) ) { add_action( 'wpdiscuz_button_actions', array( $this, 'add_honeypot_to_wpdiscuz' ) ); add_filter( 'wpdiscuz_antispam_field_prefixes', array( $this, 'wpdiscuz_field_prefixes' ) ); if ( ! has_filter( 'preprocess_comment', array( $this, 'check_comment_honeypot' ) ) ) { add_filter( 'preprocess_comment', array( $this, 'check_comment_honeypot' ) ); } } // ── MemberPress ───────────────────────────────────────────────── if ( $this->is_on( 'memberpress' ) ) { add_action( 'mepr-checkout-before-submit', array( $this, 'add_honeypot_to_memberpress' ) ); add_filter( 'mepr-validate-signup', array( $this, 'check_memberpress' ) ); } // ── Paid Memberships Pro ──────────────────────────────────────── if ( $this->is_on( 'pmpro' ) ) { add_action( 'pmpro_checkout_before_submit_button', array( $this, 'add_honeypot_to_pmpro' ) ); if ( defined( 'PMPRO_VERSION' ) && version_compare( PMPRO_VERSION, '3.2', '>=' ) ) { add_filter( 'pmpro_checkout_checks', array( $this, 'check_pmpro' ) ); } else { add_filter( 'pmpro_registration_checks', array( $this, 'check_pmpro' ) ); } } // ── GiveWP (option-based forms) ───────────────────────────────── if ( $this->is_on( 'givewp' ) ) { add_action( 'give_donation_form_top', array( $this, 'add_honeypot_to_givewp' ) ); add_action( 'give_embed_head', array( $this, 'enqueue_for_givewp_embed' ), 3 ); add_action( 'give_checkout_error_checks', array( $this, 'check_givewp' ) ); } // ── Login throttle, bot test ──────────────────────────────────── if ( $this->is_login_throttle_on() ) { add_filter( 'authenticate', array( $this, 'throttle_login' ), 30, 2 ); add_action( 'wp_login_failed', array( $this, 'count_failed_login' ) ); add_action( 'wp_login', array( $this, 'clear_login_throttle' ), 10, 2 ); } add_action( 'init', array( $this, 'watch_bot_test' ) ); } /** * Check if integration is both enabled in settings AND its plugin dependency is active. * Used during hook registration — short-circuit helper. */ private function is_on( $key ) { if ( ! $this->is_available( $key ) ) { return false; } // Check user setting. $options = $this->get_settings(); return ! isset( $options[ $key ] ) || ! empty( $options[ $key ] ); } /** * Whether an integration's plugin is active (core integrations always are). * * @param string $key Integration key. * @return bool */ private function is_available( $key ) { if ( ! isset( $this->integrations[ $key ] ) ) { return false; } $check = $this->integrations[ $key ]['check']; return '__return_true' === $check || (bool) call_user_func( $check ); } // ===================================================================== // Integration Definitions // ===================================================================== /** * Integration definitions: settings group and the dependency check. * Labels are translated separately, at render time (see get_integration_texts()). */ private function define_integrations() { $this->integrations = array( // WordPress Core. 'wp_comments' => array( 'group' => 'core', 'check' => '__return_true', ), 'wp_login' => array( 'group' => 'core', 'check' => '__return_true', ), 'wp_registration' => array( 'group' => 'core', 'check' => '__return_true', ), 'wp_lost_password' => array( 'group' => 'core', 'check' => '__return_true', ), // Developer API. 'custom_forms' => array( 'group' => 'custom', 'check' => '__return_true', ), // WooCommerce. 'woo_checkout' => array( 'group' => 'woocommerce', 'check' => array( $this, 'is_woocommerce_active' ), ), 'woo_login' => array( 'group' => 'woocommerce', 'check' => array( $this, 'is_woocommerce_active' ), ), 'woo_register' => array( 'group' => 'woocommerce', 'check' => array( $this, 'is_woocommerce_active' ), ), 'woo_block_checkout' => array( 'group' => 'woocommerce', 'check' => array( $this, 'is_woocommerce_active' ), ), 'woo_pay_order' => array( 'group' => 'woocommerce', 'check' => array( $this, 'is_woocommerce_active' ), ), 'woo_account_details' => array( 'group' => 'woocommerce', 'check' => array( $this, 'is_woocommerce_active' ), ), // Form Plugins. 'cf7' => array( 'group' => 'forms', 'check' => array( $this, 'is_cf7_active' ), ), 'gravity_forms' => array( 'group' => 'forms', 'check' => array( $this, 'is_gravity_forms_active' ), ), 'wpforms' => array( 'group' => 'forms', 'check' => array( $this, 'is_wpforms_active' ), ), 'formidable' => array( 'group' => 'forms', 'check' => array( $this, 'is_formidable_active' ), ), 'fluent_forms' => array( 'group' => 'forms', 'check' => array( $this, 'is_fluent_forms_active' ), ), 'ninja_forms' => array( 'group' => 'forms', 'check' => array( $this, 'is_ninja_forms_active' ), ), 'forminator' => array( 'group' => 'forms', 'check' => array( $this, 'is_forminator_active' ), ), 'html_forms' => array( 'group' => 'forms', 'check' => array( $this, 'is_html_forms_active' ), ), 'quform' => array( 'group' => 'forms', 'check' => array( $this, 'is_quform_active' ), ), 'cscf' => array( 'group' => 'forms', 'check' => array( $this, 'is_cscf_active' ), ), 'jetpack_forms' => array( 'group' => 'forms', 'check' => array( $this, 'is_jetpack_forms_active' ), ), 'kadence_forms' => array( 'group' => 'forms', 'check' => array( $this, 'is_kadence_active' ), ), 'ws_form' => array( 'group' => 'forms', 'check' => array( $this, 'is_ws_form_active' ), ), 'jetformbuilder' => array( 'group' => 'forms', 'check' => array( $this, 'is_jetformbuilder_active' ), ), 'sureforms' => array( 'group' => 'forms', 'check' => array( $this, 'is_sureforms_active' ), ), 'everest_forms' => array( 'group' => 'forms', 'check' => array( $this, 'is_everest_active' ), ), // Page Builders. 'elementor' => array( 'group' => 'builders', 'check' => array( $this, 'is_elementor_pro_active' ), ), 'bricks' => array( 'group' => 'builders', 'check' => array( $this, 'is_bricks_active' ), ), 'beaver' => array( 'group' => 'builders', 'check' => array( $this, 'is_beaver_active' ), ), // Community & Membership. 'bbpress' => array( 'group' => 'community', 'check' => array( $this, 'is_bbpress_active' ), ), 'buddypress' => array( 'group' => 'community', 'check' => array( $this, 'is_buddypress_active' ), ), 'ultimate_member' => array( 'group' => 'community', 'check' => array( $this, 'is_ultimate_member_active' ), ), 'userswp' => array( 'group' => 'community', 'check' => array( $this, 'is_userswp_active' ), ), 'wpdiscuz' => array( 'group' => 'community', 'check' => array( $this, 'is_wpdiscuz_active' ), ), 'memberpress' => array( 'group' => 'community', 'check' => array( $this, 'is_memberpress_active' ), ), 'pmpro' => array( 'group' => 'community', 'check' => array( $this, 'is_pmpro_active' ), ), // Marketing & eCommerce. 'mc4wp' => array( 'group' => 'marketing', 'check' => array( $this, 'is_mc4wp_active' ), ), 'edd' => array( 'group' => 'marketing', 'check' => array( $this, 'is_edd_active' ), ), 'mailpoet' => array( 'group' => 'marketing', 'check' => array( $this, 'is_mailpoet_active' ), ), 'brevo' => array( 'group' => 'marketing', 'check' => array( $this, 'is_brevo_active' ), ), 'givewp' => array( 'group' => 'marketing', 'check' => array( $this, 'is_givewp_active' ), ), ); } /** * Translated settings group headings. * * @return array */ private function get_group_labels() { return array( 'core' => __( 'WordPress Core', 'accessnow-spam-eater' ), 'custom' => __( 'Custom Forms', 'accessnow-spam-eater' ), 'woocommerce' => 'WooCommerce', 'forms' => __( 'Form Plugins', 'accessnow-spam-eater' ), 'builders' => __( 'Page Builders', 'accessnow-spam-eater' ), 'community' => __( 'Community & Membership', 'accessnow-spam-eater' ), 'marketing' => __( 'Marketing & eCommerce', 'accessnow-spam-eater' ), ); } /** * Translated label and description for each integration. Product names are not translated. * * @return array */ private function get_integration_texts() { return array( 'wp_comments' => array( __( 'Comment Forms', 'accessnow-spam-eater' ), __( 'Protect comment and review forms (includes WooCommerce Reviews)', 'accessnow-spam-eater' ) ), 'wp_login' => array( __( 'Login Form', 'accessnow-spam-eater' ), __( 'Protect the WordPress login form (also covers Theme My Login)', 'accessnow-spam-eater' ) ), 'wp_registration' => array( __( 'Registration Form', 'accessnow-spam-eater' ), __( 'Protect the registration form (also covers Theme My Login, S2Member, YITH Easy Login)', 'accessnow-spam-eater' ) ), 'wp_lost_password' => array( __( 'Lost Password Form', 'accessnow-spam-eater' ), __( 'Protect the lost password form', 'accessnow-spam-eater' ) ), 'custom_forms' => array( __( 'Custom forms', 'accessnow-spam-eater' ), __( 'Protect forms built with the developer functions accessnow_spam_eater_field() and accessnow_spam_eater_is_spam()', 'accessnow-spam-eater' ) ), 'woo_checkout' => array( __( 'Checkout', 'accessnow-spam-eater' ), __( 'Protect the checkout form (blocks card testing)', 'accessnow-spam-eater' ) ), 'woo_login' => array( __( 'My Account Login', 'accessnow-spam-eater' ), __( 'Protect WooCommerce login form', 'accessnow-spam-eater' ) ), 'woo_register' => array( __( 'My Account Registration', 'accessnow-spam-eater' ), __( 'Protect WooCommerce registration form', 'accessnow-spam-eater' ) ), 'woo_block_checkout' => array( __( 'Block checkout', 'accessnow-spam-eater' ), __( 'Protect orders placed with the Checkout block and the Cart block’s express payment buttons (blocks card testing through the Store API)', 'accessnow-spam-eater' ) ), 'woo_pay_order' => array( __( 'Pay for Order', 'accessnow-spam-eater' ), __( 'Protect the form for paying an existing order from its link', 'accessnow-spam-eater' ) ), 'woo_account_details' => array( __( 'My Account Details', 'accessnow-spam-eater' ), __( 'Protect the account details form in My Account', 'accessnow-spam-eater' ) ), 'cf7' => array( 'Contact Form 7', __( 'Protect all CF7 forms', 'accessnow-spam-eater' ) ), 'gravity_forms' => array( 'Gravity Forms', __( 'Protect all Gravity Forms', 'accessnow-spam-eater' ) ), 'wpforms' => array( 'WPForms', __( 'Protect all WPForms', 'accessnow-spam-eater' ) ), 'formidable' => array( 'Formidable Forms', __( 'Protect all Formidable Forms', 'accessnow-spam-eater' ) ), 'fluent_forms' => array( 'Fluent Forms', __( 'Protect all Fluent Forms', 'accessnow-spam-eater' ) ), 'ninja_forms' => array( 'Ninja Forms', __( 'Protect all Ninja Forms', 'accessnow-spam-eater' ) ), 'forminator' => array( 'Forminator', __( 'Protect all Forminator forms', 'accessnow-spam-eater' ) ), 'html_forms' => array( 'HTML Forms', __( 'Protect all HTML Forms', 'accessnow-spam-eater' ) ), 'quform' => array( 'QuForm', __( 'Protect all QuForm forms', 'accessnow-spam-eater' ) ), 'cscf' => array( 'Clean and Simple Contact Form', __( 'Protect the contact form', 'accessnow-spam-eater' ) ), 'jetpack_forms' => array( 'Jetpack Forms', __( 'Protect Jetpack contact forms', 'accessnow-spam-eater' ) ), 'kadence_forms' => array( 'Kadence Forms', __( 'Protect the Kadence Blocks Form and Advanced Form blocks', 'accessnow-spam-eater' ) ), 'ws_form' => array( 'WS Form', __( 'Protect all WS Form forms', 'accessnow-spam-eater' ) ), 'jetformbuilder' => array( 'JetFormBuilder', __( 'Protect all JetFormBuilder forms', 'accessnow-spam-eater' ) ), 'sureforms' => array( 'SureForms', __( 'Protect all SureForms forms', 'accessnow-spam-eater' ) ), 'everest_forms' => array( 'Everest Forms', __( 'Protect all Everest Forms', 'accessnow-spam-eater' ) ), 'elementor' => array( 'Elementor Pro Forms', __( 'Protect Elementor Pro form widgets', 'accessnow-spam-eater' ) ), 'bricks' => array( 'Bricks Builder', __( 'Protect Bricks form elements', 'accessnow-spam-eater' ) ), 'beaver' => array( 'Beaver Builder', __( 'Protect Beaver Builder contact form module', 'accessnow-spam-eater' ) ), 'bbpress' => array( 'bbPress', __( 'Protect topic and reply forms', 'accessnow-spam-eater' ) ), 'buddypress' => array( 'BuddyPress', __( 'Protect registration (also covers BuddyBoss & Youzify)', 'accessnow-spam-eater' ) ), 'ultimate_member' => array( 'Ultimate Member', __( 'Protect registration forms', 'accessnow-spam-eater' ) ), 'userswp' => array( 'UsersWP', __( 'Protect registration and login forms', 'accessnow-spam-eater' ) ), 'wpdiscuz' => array( 'wpDiscuz', __( 'Protect wpDiscuz comment forms (when this is off, Spam Eater leaves wpDiscuz comments alone)', 'accessnow-spam-eater' ) ), 'memberpress' => array( 'MemberPress', __( 'Protect the registration and checkout form', 'accessnow-spam-eater' ) ), 'pmpro' => array( 'Paid Memberships Pro', __( 'Protect the membership checkout form', 'accessnow-spam-eater' ) ), 'mc4wp' => array( 'MC4WP Mailchimp', __( 'Protect Mailchimp signup forms', 'accessnow-spam-eater' ) ), 'edd' => array( 'Easy Digital Downloads', __( 'Protect EDD checkout form', 'accessnow-spam-eater' ) ), 'mailpoet' => array( 'MailPoet', __( 'Protect MailPoet subscription forms (the script then loads on every page, because MailPoet can show a form in a pop-up anywhere)', 'accessnow-spam-eater' ) ), 'brevo' => array( 'Brevo', __( 'Protect Brevo (Sendinblue) subscription forms', 'accessnow-spam-eater' ) ), 'givewp' => array( 'GiveWP', __( 'Protect option-based donation forms. Forms made with GiveWP’s visual form builder are not covered yet.', 'accessnow-spam-eater' ) ), ); } // ===================================================================== // Plugin Detection Helpers // ===================================================================== public function is_woocommerce_active() { return class_exists( 'WooCommerce' ); } public function is_cf7_active() { return defined( 'WPCF7_VERSION' ); } public function is_gravity_forms_active() { return class_exists( 'GFForms' ); } public function is_wpforms_active() { return function_exists( 'wpforms' ); } public function is_formidable_active() { return class_exists( 'FrmAppHelper' ); } public function is_fluent_forms_active() { return defined( 'FLUENTFORM_VERSION' ); } public function is_bbpress_active() { return class_exists( 'bbPress' ); } public function is_ninja_forms_active() { return function_exists( 'Ninja_Forms' ); } public function is_forminator_active() { return class_exists( 'Forminator' ); } public function is_html_forms_active() { return class_exists( 'HTML_Forms\\HTML_Forms' ) || defined( 'HTML_FORMS_VERSION' ); } public function is_buddypress_active() { return class_exists( 'BuddyPress' ); } public function is_ultimate_member_active() { return function_exists( 'UM' ); } public function is_userswp_active() { return class_exists( 'UsersWP' ); } public function is_mc4wp_active() { return function_exists( 'mc4wp_get_form' ); } public function is_edd_active() { return class_exists( 'Easy_Digital_Downloads' ); } public function is_elementor_pro_active() { return defined( 'ELEMENTOR_PRO_VERSION' ); } public function is_bricks_active() { return defined( 'BRICKS_VERSION' ); } public function is_beaver_active() { return class_exists( 'FLBuilder' ); } public function is_quform_active() { return class_exists( 'Quform' ) || class_exists( 'Quform_Builder' ); } public function is_cscf_active() { return class_exists( 'cscf' ) || defined( 'CSCF_VERSION_NUM' ); } public function is_jetpack_forms_active() { return class_exists( '\\Automattic\\Jetpack\\Forms\\ContactForm\\Contact_Form' ); } public function is_kadence_active() { return defined( 'KADENCE_BLOCKS_VERSION' ); } public function is_ws_form_active() { return defined( 'WS_FORM_VERSION' ); } public function is_jetformbuilder_active() { return defined( 'JET_FORM_BUILDER_VERSION' ) || function_exists( 'jet_form_builder' ); } public function is_sureforms_active() { return defined( 'SRFM_VER' ); } public function is_everest_active() { return defined( 'EVF_PLUGIN_FILE' ) || class_exists( 'EverestForms' ); } public function is_wpdiscuz_active() { return defined( 'WPDISCUZ_DIR_PATH' ) || class_exists( 'WpdiscuzCore' ); } public function is_memberpress_active() { return defined( 'MEPR_VERSION' ); } public function is_pmpro_active() { return defined( 'PMPRO_VERSION' ); } public function is_mailpoet_active() { return defined( 'MAILPOET_VERSION' ); } public function is_brevo_active() { return class_exists( 'SIB_Manager' ); } public function is_givewp_active() { return defined( 'GIVE_VERSION' ); } // ===================================================================== // Stored data upgrade // ===================================================================== /** * Upgrade stored data, one data version at a time. Guarded by the stored data * version; each step is idempotent and safe to run twice. */ public function maybe_upgrade() { $stored = (int) get_option( 'accessnow_spam_eater_db_version', 0 ); if ( $stored >= self::DB_VERSION ) { return; } // Spam Eater ran here before: 2.1.x recorded data version 1, 2.0.x left unprefixed options. $existing = $stored >= 1 || self::MISSING !== get_option( 'spam_eater_field_name', self::MISSING ) || self::MISSING !== get_option( 'spam_eater_settings', self::MISSING ) || self::MISSING !== get_option( 'accessnow_spam_eater_field_name', self::MISSING ); if ( $stored < 1 && ! $this->upgrade_to_1() ) { return; // A copy failed: try again on the next load. } if ( $stored < 2 ) { $this->upgrade_to_2( $existing ); } if ( $stored < 3 && $existing ) { // 3.0.0: pages already open still carry the old page-wide interaction token: accept it for a day. update_option( 'accessnow_spam_eater_act_grace_until', time() + self::ACT_GRACE, false ); } update_option( 'accessnow_spam_eater_db_version', self::DB_VERSION, true ); } /** * Data version 2 (Spam Eater 2.2): the script loads only on pages with a protected * form. Sites that already ran Spam Eater keep loading it on every page, as before, * so a form fetched after the page loads (a pop-up) keeps working after the update. * * @param bool $existing Whether Spam Eater ran on this site before. */ private function upgrade_to_2( $existing ) { if ( ! $existing ) { return; } $settings = get_option( 'accessnow_spam_eater_settings', array() ); $settings = is_array( $settings ) ? $settings : array(); if ( ! isset( $settings['load_everywhere'] ) ) { $settings['load_everywhere'] = 1; update_option( 'accessnow_spam_eater_settings', $settings ); $this->cached_settings = null; } } /** * Data version 1 (Spam Eater 2.1): move stored data from SPAM Eater 2.0.x * (unprefixed option names). Each old option is copied first and only deleted * once the copy is confirmed. * * @return bool True when every copy succeeded. */ private function upgrade_to_1() { $is_upgrade = self::MISSING !== get_option( 'spam_eater_field_name', self::MISSING ) || self::MISSING !== get_option( 'spam_eater_settings', self::MISSING ); // 1. Settings, honeypot field name and blocked counter move to prefixed names. $complete = $this->move_option( 'spam_eater_settings', 'accessnow_spam_eater_settings', true ); $complete = $this->move_option( 'spam_eater_field_name', 'accessnow_spam_eater_field_name', true ) && $complete; $complete = $this->move_option( 'spam_eater_blocked_count', 'accessnow_spam_eater_blocked_count', false ) && $complete; // 2. The block list was keyed by raw IP address. Re-key it by a keyed hash, // keep only an anonymised IP for display, and move it to the new name. $old_blocked = get_option( 'spam_eater_blocked_ips', self::MISSING ); if ( self::MISSING !== $old_blocked ) { $new_blocked = get_option( 'accessnow_spam_eater_blocked_ips', array() ); $merged = array_merge( is_array( $old_blocked ) ? $this->convert_legacy_blocklist( $old_blocked ) : array(), is_array( $new_blocked ) ? $new_blocked : array() ); update_option( 'accessnow_spam_eater_blocked_ips', $merged, false ); $this->cached_blocked = null; if ( get_option( 'accessnow_spam_eater_blocked_ips', self::MISSING ) == $merged ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- Stored scalars may come back as strings. delete_option( 'spam_eater_blocked_ips' ); } else { $complete = false; } } // 3. The old log held raw IP addresses and up to 200 characters of every // submitted form field. That is personal data we must not keep, so it is // deleted rather than migrated. delete_option( 'spam_eater_log' ); // 4. Pages cached before the upgrade still carry the old static tokens. // Accept them for a short grace period so real visitors are not blocked. if ( $is_upgrade && false === get_option( 'accessnow_spam_eater_legacy_until', false ) ) { add_option( 'accessnow_spam_eater_legacy_until', time() + self::LEGACY_TOKEN_GRACE, '', false ); } $this->cached_settings = null; $this->cached_field_name = null; return $complete; } /** * Copy an option to a new name, then delete the old one once the copy is confirmed. * If the new option already exists (an earlier, interrupted run) it is kept. * * @param string $old_name Old option name. * @param string $new_name New option name. * @param bool $autoload Autoload the new option. * @return bool True when the old option is gone (or never existed). */ private function move_option( $old_name, $new_name, $autoload ) { $value = get_option( $old_name, self::MISSING ); if ( self::MISSING === $value ) { return true; } if ( self::MISSING === get_option( $new_name, self::MISSING ) ) { add_option( $new_name, $value, '', $autoload ); if ( get_option( $new_name, self::MISSING ) != $value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Stored scalars may come back as strings. return false; // Copy failed: keep the old option and try again later. } } delete_option( $old_name ); return true; } /** * Convert a pre-2.1 block list (raw IP => data) to the hashed format. * Entries already in the new format are kept as they are. * * @param array $blocked Stored block list. * @return array */ private function convert_legacy_blocklist( $blocked ) { $now = time(); $converted = array(); foreach ( $blocked as $key => $entry ) { if ( ! is_array( $entry ) ) { continue; } if ( isset( $entry['label'] ) && preg_match( '/^[a-f0-9]{32}$/', (string) $key ) ) { $converted[ $key ] = $entry; // Already converted. continue; } $ip = $this->normalise_ip( (string) $key ); if ( '' === $ip ) { continue; } $blocked_at = isset( $entry['blocked_at'] ) ? (int) $entry['blocked_at'] : 0; $converted[ $this->ip_key( $ip ) ] = array( 'count' => isset( $entry['count'] ) ? (int) $entry['count'] : 0, 'blocked_at' => $blocked_at, 'last_seen' => $blocked_at ? $blocked_at : $now, 'label' => wp_privacy_anonymize_ip( $ip ), ); } return $converted; } // ===================================================================== // Cached Option Accessors // ===================================================================== private function get_settings() { if ( null === $this->cached_settings ) { $settings = get_option( 'accessnow_spam_eater_settings', array() ); $this->cached_settings = is_array( $settings ) ? $settings : array(); } return $this->cached_settings; } private function get_blocked_ips() { if ( null === $this->cached_blocked ) { $blocked = get_option( 'accessnow_spam_eater_blocked_ips', array() ); $this->cached_blocked = is_array( $blocked ) ? $blocked : array(); } return $this->cached_blocked; } private function save_blocked_ips( $blocked ) { $this->cached_blocked = $blocked; update_option( 'accessnow_spam_eater_blocked_ips', $blocked, false ); } // ===================================================================== // Core Helpers // ===================================================================== private function get_field_name() { if ( null !== $this->cached_field_name ) { return $this->cached_field_name; } $name = get_option( 'accessnow_spam_eater_field_name' ); if ( empty( $name ) ) { // Upgrade not finished yet: keep using the 2.0.x name so cached forms still match. $name = get_option( 'spam_eater_field_name' ); } if ( empty( $name ) ) { $name = 'se_' . substr( wp_hash( 'spam_eater_field_' . get_site_url() . wp_salt() ), 0, 8 ); update_option( 'accessnow_spam_eater_field_name', $name, true ); } $this->cached_field_name = $name; return $name; } /** * Get the client IP. * * REMOTE_ADDR is used unless the request comes from a proxy listed in the * "Trusted proxies" setting. Only then is X-Forwarded-For read, from right * to left, skipping the trusted proxies. The header is never trusted by default. */ private function get_client_ip() { if ( null !== $this->cached_ip ) { return $this->cached_ip; } $server = array(); foreach ( array( 'REMOTE_ADDR', 'HTTP_X_FORWARDED_FOR' ) as $key ) { if ( isset( $_SERVER[ $key ] ) ) { $server[ $key ] = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); } } $remote = isset( $server['REMOTE_ADDR'] ) ? Ip::normalise( $server['REMOTE_ADDR'] ) : ''; $ip = Ip::client( $server, $this->get_trusted_proxies() ); /** * Filters the client IP address used for blocking and logging. * * @param string $ip Detected client IP. * @param string $remote REMOTE_ADDR of the request. */ $ip = (string) apply_filters( 'accessnow_spam_eater_client_ip', $ip, $remote ); $this->cached_ip = $ip; return $this->cached_ip; } /** * Validate and normalise an IP address. Strips ports and IPv6 brackets. * * @param string $ip Raw value. * @return string Normalised IP, or '' when invalid. */ private function normalise_ip( $ip ) { return Ip::normalise( $ip ); } /** * Trusted proxy addresses/ranges from the settings. * * @return string[] */ private function get_trusted_proxies() { $settings = $this->get_settings(); return ( isset( $settings['trusted_proxies'] ) && is_array( $settings['trusted_proxies'] ) ) ? $settings['trusted_proxies'] : array(); } /** * Whether an IP matches any address or CIDR range in the list. * * @param string $ip Normalised IP. * @param string[] $ranges IPs or CIDR ranges. * @return bool */ private function ip_matches_any( $ip, $ranges ) { return Ip::matchesAny( $ip, (array) $ranges ); } /** * Clean an IP list textarea ("Trusted proxies", "Always allow", "Always block") into a * list of valid IPs/CIDR ranges, IPv4 and IPv6. Anything else on a line is dropped. * * @param mixed $raw Raw input. * @return string[] */ private function sanitize_trusted_proxies( $raw ) { if ( is_array( $raw ) ) { $raw = implode( "\n", $raw ); } $clean = array(); foreach ( preg_split( '/[\s,]+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY ) as $item ) { $bits = null; if ( false !== strpos( $item, '/' ) ) { list( $item, $bits ) = explode( '/', $item, 2 ); } $ip = $this->normalise_ip( $item ); if ( '' === $ip ) { continue; } if ( null !== $bits ) { $max = ( false !== strpos( $ip, ':' ) ) ? 128 : 32; if ( ! ctype_digit( $bits ) || (int) $bits > $max ) { continue; } $ip .= '/' . (int) $bits; } $clean[] = $ip; } return array_slice( array_values( array_unique( $clean ) ), 0, 200 ); } /** * Keyed, one-way hash of an IP address, used as the block list key so raw * addresses are never stored. * * @param string $ip Normalised IP. * @return string */ private function ip_key( $ip ) { return substr( hash_hmac( 'sha256', 'spam-eater-ip|' . $ip, wp_salt( 'auth' ) ), 0, 32 ); } /** * Load the script up front only where it is needed before any field is printed: * everywhere when the site asks for it (or MailPoet, whose pop-ups can appear on any * page, is protected), on checkout for the field fallback, and on pages with the * Cart or Checkout block. Everywhere else it loads when a form prints its fields * (see render_field()). */ public function enqueue_assets() { $settings = $this->get_settings(); if ( ! empty( $settings['load_everywhere'] ) || $this->is_on( 'mailpoet' ) || ( $this->is_on( 'woo_checkout' ) && function_exists( 'is_checkout' ) && is_checkout() ) ) { $this->enqueue_frontend(); } $this->maybe_enqueue_store_api(); } /** * Enqueue the front-end script and its config, once per request. Called late * (while a form is printed), it lands in the footer, which is where it goes anyway. */ private function enqueue_frontend() { if ( wp_script_is( 'accessnow-spam-eater', 'enqueued' ) || ! did_action( 'init' ) ) { return; } wp_enqueue_script( 'accessnow-spam-eater', plugins_url( 'assets/js/spam-eater.js', ACCESSNOW_SPAM_EATER_FILE ), array(), ACCESSNOW_SPAM_EATER_VERSION, true ); $config = array_merge( $this->get_token_payload(), array( 'refreshAfter' => (int) floor( $this->token_lifetime() / 4 ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'action' => 'accessnow_spam_eater_tokens', 'actAction' => 'accessnow_spam_eater_act', 'field' => $this->get_field_name(), 'inject' => $this->get_inject_targets(), 'botTest' => $this->get_bot_test_config(), ) ); wp_add_inline_script( 'accessnow-spam-eater', 'var accessnowSpamEaterConfig = ' . wp_json_encode( $config ) . ';', 'before' ); } /** * Forms the script adds the hidden fields to when the server could not: a checkout * template or checkout plugin that skips the woocommerce_after_order_notes hook, and * forms drawn or placed without a hook inside them (WS Form, MailPoet pop-ups, the * Brevo widget). Without this every submission from them would be refused. * * @return array CSS selector, and the array name the * fields go in ('' for none). */ private function get_inject_targets() { $targets = array(); foreach ( $this->get_inject_selectors() as $selector ) { $targets[] = array( 's' => $selector, 'g' => '', ); } if ( $this->is_on( 'ws_form' ) ) { $targets[] = array( 's' => 'form.wsf-form', 'g' => '', ); } if ( $this->is_on( 'brevo' ) ) { $targets[] = array( 's' => 'form.sib_signup_form', 'g' => '', ); } if ( $this->is_on( 'mailpoet' ) ) { $targets[] = array( 's' => 'form.mailpoet_form_form', 'g' => 'data', ); } return $targets; } /** * CSS selectors of forms that get the fields from the script (see get_inject_targets()). * * @return string[] CSS selectors. */ private function get_inject_selectors() { $selectors = array(); if ( $this->is_on( 'woo_checkout' ) ) { $selectors[] = 'form.checkout'; } /** * Filters the forms the script adds Spam Eater's hidden fields to when they are missing. * * @param string[] $selectors CSS selectors for form elements. */ $selectors = (array) apply_filters( 'accessnow_spam_eater_inject_selectors', $selectors ); return array_values( array_filter( array_map( 'strval', $selectors ) ) ); } /** * Current tokens, for the page config and the refresh endpoint. * * @return array */ private function get_token_payload() { return array( 'js' => $this->get_js_token(), 'ts' => $this->sign_timestamp(), 'issued' => time(), ); } /** * AJAX: fresh tokens for a page that was served from a page cache. * Public and read-only: it returns nothing a visitor could not read from the page. */ public function ajax_tokens() { nocache_headers(); wp_send_json_success( $this->get_token_payload() ); } /** * AJAX: the visitor's own interaction token, which the script asks for (POST, se_ts = the form's * timestamp) once they first touch the page. It is never printed in the page, so a bot that reads * the page can't reuse it; it belongs to that timestamp (a fresh one comes back if it has expired), * and run_checks() lets each one pass a few submissions only. * * Public by design and without a nonce, like ajax_tokens(): anyone who can load the page may get a * token, pages are often served from a page cache (where a nonce would be stale), and the token * grants nothing but the right to have a form checked. The only thing it stores is a per-address * count (a keyed hash, expiring within the hour) that limits how many tokens one address can get. */ public function ajax_act() { nocache_headers(); $ip = $this->get_client_ip(); $max = (int) apply_filters( 'accessnow_spam_eater_act_per_hour', self::ACT_PER_HOUR ); if ( '' !== $ip && $max > 0 ) { $key = 'accessnow_spam_eater_issued_' . $this->ip_key( $ip ); $count = (int) get_transient( $key ); if ( $count >= $max ) { wp_send_json_error( null, 429 ); } set_transient( $key, $count + 1, HOUR_IN_SECONDS ); } $stamp = (string) $this->get_posted( 'se_ts' ); $check = $this->verify_timestamp( $stamp ); if ( false === $check || $check['legacy'] || $check['age'] > $this->token_lifetime() ) { $stamp = $this->sign_timestamp(); } wp_send_json_success( array( 'ts' => $stamp, 'act' => $this->tokens()->actFor( $stamp ), ) ); } /** * Count a passing use of a visitor's interaction token; false once it has had its uses. * * @param string $id The token's id (Tokens::verifyAct()). * @param bool $count Count this use (false for a dry run: only look). * @return bool */ private function use_act_token( $id, $count = true ) { $max = (int) apply_filters( 'accessnow_spam_eater_token_uses', self::TOKEN_USES ); if ( $max <= 0 ) { return true; } $key = 'accessnow_spam_eater_used_' . $id; $used = (int) get_transient( $key ); if ( $used >= $max ) { return false; } if ( $count ) { set_transient( $key, $used + 1, $this->token_lifetime() ); } return true; } /** * Whether the old page-wide interaction token still counts (the day after upgrading to 3.0.0). * * @return bool */ private function act_grace() { $until = (int) get_option( 'accessnow_spam_eater_act_grace_until', 0 ); if ( $until > time() ) { return true; } if ( $until > 0 ) { delete_option( 'accessnow_spam_eater_act_grace_until' ); } return false; } /** * Token lifetime in seconds. Tokens rotate every half lifetime. * * @return int */ private function token_lifetime() { /** * Filters how long anti-spam tokens stay valid, in seconds (minimum one hour). * * @param int $lifetime Lifetime in seconds. Default two days. */ $lifetime = (int) apply_filters( 'accessnow_spam_eater_token_lifetime', self::DEFAULT_TOKEN_LIFETIME ); return max( HOUR_IN_SECONDS, $lifetime ); } /** * The engine that signs and checks tokens, keyed with a site secret. * * @return Tokens */ private function tokens() { if ( null === $this->tokens ) { $this->tokens = new Tokens( wp_salt( 'nonce' ), $this->token_lifetime() ); } return $this->tokens; } /** * HMAC-sign a value with a site secret. * * @param string $data Data to sign. * @return string */ private function sign( $data ) { return $this->tokens()->sign( $data ); } private function get_js_token() { return $this->tokens()->token( 'js' ); } /** * Check a rotating token. Accepts the current and previous tick, so a token * is valid for between half and the full lifetime. * * @param string $purpose 'js' or 'act'. * @param string $value Submitted token. * @return bool */ private function verify_token( $purpose, $value ) { if ( ! is_string( $value ) || '' === $value ) { return false; } if ( $this->tokens()->verify( $purpose, $value ) ) { return true; } // Pre-2.1 static tokens, accepted only during the post-upgrade grace period. return $this->legacy_tokens_allowed() && hash_equals( wp_hash( 'spam_eater_' . $purpose . '_verify' ), $value ); } /** * Whether pre-2.1 tokens are still accepted (grace period after upgrading). * * @return bool */ private function legacy_tokens_allowed() { $until = (int) get_option( 'accessnow_spam_eater_legacy_until', 0 ); if ( $until > time() ) { return true; } if ( $until > 0 ) { delete_option( 'accessnow_spam_eater_legacy_until' ); // Grace period over: stop looking it up. } return false; } private function sign_timestamp() { return $this->tokens()->stamp(); } /** * Verify a signed timestamp. * * @param string $signed "time:signature". * @return array|false array( 'age' => seconds, 'legacy' => bool ), or false when invalid. */ private function verify_timestamp( $signed ) { $parts = Tokens::splitStamp( $signed ); if ( null === $parts ) { return false; } $age = $this->tokens()->verifyStamp( $signed ); if ( null !== $age ) { return array( 'age' => $age, 'legacy' => false, ); } if ( $this->legacy_tokens_allowed() && hash_equals( wp_hash( 'se_ts_' . $parts[0] ), $parts[1] ) ) { return array( 'age' => time() - (int) $parts[0], 'legacy' => true, ); } return false; } /** * Render the hidden fields. The hiding styles are inline, so no stylesheet is * needed and the fields stay hidden wherever the markup ends up. * * @param string $context Short name of the form, used in the field ID. * @param string $group Optional array name to put the fields in, for forms that only * send their own group of fields (MailPoet sends data[...] only). * @return string */ private function render_field( $context = '', $group = '' ) { $field_name = $this->get_field_name(); $id = 'se-' . $field_name . ( '' !== $context ? '-' . $context : '' ) . '-' . ( ++$this->field_count ); $timestamp = $this->sign_timestamp(); $name = function ( $key ) use ( $group ) { return '' === $group ? $key : $group . '[' . $key . ']'; }; $this->enqueue_frontend(); return ''; } /** * Print the hidden fields, for hooks that echo rather than filter. * * @param string $context Short name of the form. */ private function print_field( $context = '' ) { echo wp_kses( $this->render_field( $context ), array( 'div' => array( 'class' => true, 'aria-hidden' => true, 'style' => true, ), 'label' => array( 'for' => true ), 'input' => array( 'type' => true, 'name' => true, 'id' => true, 'class' => true, 'value' => true, 'tabindex' => true, 'autocomplete' => true, ), ) ); } /** * Read a submitted field for the spam check. * * @param string $key POST key. * @return string|null Sanitised string ('' when absent), or null when an array was sent. */ private function get_posted( $key ) { $value = null !== $this->request_data ? ( isset( $this->request_data[ $key ] ) ? $this->request_data[ $key ] : null ) : $this->read_post( $key ); if ( null === $value ) { return ''; } return is_string( $value ) ? sanitize_text_field( $value ) : null; } /** * The one place Spam Eater reads $_POST: one submitted value, or all of them, * unslashed and sanitised in depth. * * There is deliberately no nonce check. These reads only run inside another form's * handler (a comment, Contact Form 7, WooCommerce, the login form…) after that * handler has accepted the request, for a read-only spam check that stores nothing * from the form. The visitor is usually logged out and the page usually cached, so a * nonce would be the same for every visitor and protect nothing, and WooCommerce * changes a guest's nonce when their cart session starts, which would refuse real * orders. Spam Eater's own signed, expiring tokens (se_js, se_ts, se_act) are the * check of where the submission came from. Spam Eater's own admin requests are * handled elsewhere, with a nonce and a capability check. * * @param string|null $key POST key, or null for every submitted value. * @return string|array|null The value (an array when $key is null), or null when it was not sent. */ private function read_post( $key = null ) { // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only spam check inside another form's handler (see above); sanitised in depth by map_deep(). if ( null === $key ) { return map_deep( wp_unslash( $_POST ), array( $this, 'clean_submitted' ) ); } if ( ! isset( $_POST[ $key ] ) ) { return null; } return map_deep( wp_unslash( $_POST[ $key ] ), array( $this, 'clean_submitted' ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } /** * Sanitise one submitted value for the checks. Tags are stripped, but the address a * link points to is kept as text first, so a word or pattern rule such as "bit.ly" * still finds a URL hidden in . * * @param mixed $value Submitted value. * @return mixed */ public function clean_submitted( $value ) { if ( ! is_string( $value ) ) { return $value; } $value = wp_check_invalid_utf8( $value, true ); if ( false !== stripos( $value, 'href' ) && preg_match_all( '/\bhref\s*=\s*["\']?([^"\'\s>]+)/i', $value, $links ) ) { $value .= ' ' . implode( ' ', $links[1] ); } return sanitize_textarea_field( $value ); } /** * Check whether the submission is spam. Results are cached per request and * context, and a blocked submission is recorded only once, so several form * hooks firing for the same submission don't count it twice. * * Contexts: * - 'full': every layer, for forms Spam Eater adds its fields to. * - 'basic': Layers 0-1 and 5, for requests that reach a protected handler without * coming from the form (e.g. a payment gateway's express checkout). * - 'login': Layer 1 only. Credentials already protect login forms, and an IP * block must never lock a site's own users out of logging in. * * Layers: * -. Your own allow list — addresses on it skip every check (except in a bot test). * 0. IP block list and your own block list — refused immediately (not counted, to avoid a DB write storm). * 1. Traditional honeypot — hidden field must remain EMPTY. * 2. JS token verification — hidden field must be SET by JavaScript. * 3. Minimum time check — form must be open for at least N seconds (and not expired). * 4. Interaction proof — mouse/keyboard/touch/scroll must have occurred. * 5. Content — your own words and patterns, and throwaway email addresses (both opt-in). * * @param string $context 'full', 'basic' or 'login'. * @return bool */ private function is_spam( $context = 'full' ) { if ( ! in_array( $context, array( 'full', 'basic', 'login' ), true ) ) { $context = 'full'; } if ( ! isset( $this->spam_results[ $context ] ) ) { $this->checked = true; $this->spam_results[ $context ] = $this->run_checks( $context ); if ( $this->is_bot_test() ) { $this->save_bot_test_result( $this->spam_results[ $context ] ? $this->spam_fail_code : '' ); } } return $this->spam_results[ $context ]; } /** * Run the layers for a context (see is_spam()). * * @param string $context 'full', 'basic' or 'login'. * @return bool */ private function run_checks( $context ) { $ip = $this->get_client_ip(); // Your own allow list: trusted addresses skip every check. A bot test still runs them. if ( '' !== $ip && ! $this->is_bot_test() && $this->is_ip_allowlisted( $ip ) ) { return false; } if ( 'login' !== $context ) { // Layer 0: IP block list — return immediately, skip logging to avoid DB storm. if ( $this->is_ip_blocked( $ip ) ) { $this->spam_fail_code = 'SE-A0'; return true; } // Your own block list, the same way. if ( '' !== $ip && $this->ip_matches_any( $ip, $this->get_ip_list( 'ip_blocklist' ) ) ) { $this->spam_fail_code = 'SE-H0'; return true; } } // Layer 1: Traditional honeypot (an array value counts as filled). $field = $this->get_posted( $this->get_field_name() ); if ( null === $field || '' !== $field ) { return $this->flag( 'SE-B1', 'filled_honeypot' ); } if ( 'login' === $context ) { return false; } if ( 'full' === $context ) { // Layer 2: JS token. if ( ! $this->verify_token( 'js', (string) $this->get_posted( 'se_js' ) ) ) { return $this->flag( 'SE-C2', 'missing_js_token' ); } // Layer 3: Minimum time, and the form must not have expired. $stamp = $this->verify_timestamp( (string) $this->get_posted( 'se_ts' ) ); if ( false === $stamp ) { return $this->flag( 'SE-D3', 'tampered_timestamp' ); } if ( $stamp['age'] < self::MIN_SUBMIT_TIME ) { return $this->flag( 'SE-E3', 'too_fast' ); } if ( ! $stamp['legacy'] && $stamp['age'] > $this->token_lifetime() ) { return $this->flag( 'SE-G3', 'expired_token' ); } // Layer 4: Interaction proof: the visitor's own token for this form's timestamp, issued when they // touched the page (ajax_act), and not already used for too many submissions. $act = (string) $this->get_posted( 'se_act' ); $id = $this->tokens()->verifyAct( (string) $this->get_posted( 'se_ts' ), $act ); if ( null === $id ) { if ( ! ( $this->act_grace() && $this->verify_token( 'act', $act ) ) ) { return $this->flag( 'SE-F4', 'no_interaction' ); } } elseif ( ! $this->is_bot_test() && ! $this->use_act_token( $id, ! $this->dry_run ) ) { return $this->flag( 'SE-R4', 'reused_token' ); } } // Layer 5: what was submitted, checked in memory and never stored. return $this->check_content(); } /** * Mark the current submission as spam and record it (once per request). A bot * test is never recorded: it isn't spam, and it must not count towards a block. * * @param string $code Opaque reference code shown to the visitor. * @param string $reason Internal reason key. * @return true */ private function flag( $code, $reason ) { $this->spam_fail_code = $code; if ( ! $this->block_recorded && ! $this->is_bot_test() ) { $this->block_recorded = true; $this->record_block( $reason ); } return true; } // ===================================================================== // Layer 5: your own words and patterns, and throwaway email addresses // ===================================================================== /** * Check what was submitted against the site's own words and patterns and the * list of throwaway email domains. Everything happens in memory: the text is * never stored or logged, only the reason a submission was refused. * * @return bool True when the submission should be refused. */ private function check_content() { $content = $this->content(); $disposable = $this->is_setting_on( 'block_disposable' ); if ( ! $content->hasRules() && ! $disposable ) { return false; } $values = $this->get_submitted_text(); if ( empty( $values ) ) { return false; } if ( $content->matchesRules( $values ) ) { return $this->flag( 'SE-K5', 'blocked_words' ); } if ( $disposable && $content->hasDisposableEmail( $values ) ) { return $this->flag( 'SE-M5', 'disposable_email' ); } return false; } /** * The site's own words and patterns and the throwaway email domains, ready to check * (built once per request, and again when the settings change). * * @return Content */ private function content() { if ( null === $this->content ) { $settings = $this->get_settings(); $this->content = new Content( isset( $settings['block_words'] ) ? (string) $settings['block_words'] : '', function () { return $this->get_disposable_domains(); } ); } return $this->content; } /** * Whether an on/off setting is on (off when it was never saved). * * @param string $key Setting key. * @return bool */ private function is_setting_on( $key ) { $settings = $this->get_settings(); return ! empty( $settings[ $key ] ); } /** * The text values of the submission, for the content checks: every string in * the submitted data (nested arrays included), leaving out Spam Eater's own * fields and anything that looks like a password, card detail or security token. * Bounded in number and length, so a huge submission can't make the check slow. * * @return string[] */ private function get_submitted_text() { $data = null !== $this->request_data ? map_deep( (array) $this->request_data, array( $this, 'clean_submitted' ) ) : $this->read_post(); return Content::collect( $data, array( 'se_js', 'se_ts', 'se_act', 'se_test', $this->get_field_name() ) ); } /** * Clean the "words and patterns" setting. Invalid or over-long patterns are left * out and reported back on the settings page. * * @param mixed $raw Raw input. * @return string */ private function sanitize_word_rules( $raw ) { $kept = array(); $invalid = array(); foreach ( preg_split( '/\r\n|\r|\n/', is_string( $raw ) ? $raw : '' ) as $line ) { $line = trim( sanitize_textarea_field( $line ) ); if ( '' === $line ) { continue; } if ( 0 === strpos( $line, '#' ) ) { $kept[] = $line; continue; } if ( '/' === $line[0] && strlen( $line ) > 1 ) { if ( strlen( $line ) > self::RULES_MAX_PATTERN || ! Content::isRegex( $line ) || ! Content::compiles( $line ) ) { $invalid[] = $line; continue; } } $kept[] = substr( $line, 0, self::RULES_MAX_PATTERN ); } if ( ! empty( $invalid ) && function_exists( 'add_settings_error' ) ) { add_settings_error( 'accessnow_spam_eater_settings', 'accessnow_spam_eater_invalid_rules', /* translators: %s: the patterns that were left out, separated by commas. */ sprintf( __( 'These patterns were not saved because they are not valid regular expressions (or are longer than 300 characters): %s', 'accessnow-spam-eater' ), implode( ', ', $invalid ) ), 'error' ); } return implode( "\n", array_slice( array_unique( $kept ), 0, self::RULES_MAX_LINES ) ); } /** * Whether a domain, or a domain it belongs to, is on the bundled list of * throwaway email services. * * @param string $domain Domain part of an email address. * @return bool */ public function is_disposable_domain( $domain ) { return $this->content()->isDisposableDomain( $domain ); } /** * Number of throwaway email domains on the list. * * @return int */ public function count_disposable_domains() { return $this->content()->countDomains(); } /** * The bundled list of throwaway email domains (includes/data/disposable-domains.txt), * as a lookup table. Read from disk only when a submission holds an email address. * * @return array */ private function get_disposable_domains() { /** * Filters the throwaway email domains Spam Eater refuses (when that check is on). * * @param array $domains Domain => true. */ return (array) apply_filters( 'accessnow_spam_eater_disposable_domains', Content::loadDomains( ACCESSNOW_SPAM_EATER_DIR . 'includes/data/disposable-domains.txt' ) ); } // ===================================================================== // Your own allow and block lists of IP addresses // ===================================================================== /** * An IP list from the settings: 'ip_allowlist' or 'ip_blocklist' (addresses and CIDR ranges). * * @param string $key Setting key. * @return string[] */ private function get_ip_list( $key ) { $settings = $this->get_settings(); return ( isset( $settings[ $key ] ) && is_array( $settings[ $key ] ) ) ? $settings[ $key ] : array(); } /** * Whether an address is on the site's own allow list. * * @param string $ip Normalised IP. * @return bool */ private function is_ip_allowlisted( $ip ) { return '' !== $ip && $this->ip_matches_any( $ip, $this->get_ip_list( 'ip_allowlist' ) ); } // ===================================================================== // Test a form as a bot (administrators only) // ===================================================================== /** * Whether this request is an administrator's bot test: the form carries a valid * test nonce, sent by the "Test as a bot" button, from a user who can manage options. * * @return bool */ private function is_bot_test() { $nonce = (string) $this->get_posted( 'se_test' ); if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'accessnow_spam_eater_bot_test' ) ) { return false; } return current_user_can( 'manage_options' ); } /** * Keep the result of a bot test for the button to read back (ten minutes, per user). * * @param string $code Reference code of the check that refused it, '' when it was let * through, or 'unchecked' when Spam Eater never checked it. */ private function save_bot_test_result( $code ) { set_transient( 'accessnow_spam_eater_bot_test_' . get_current_user_id(), array( 'code' => $code, 'time' => microtime( true ), ), 10 * MINUTE_IN_SECONDS ); } /** * Action: init. A bot test that reached the site in an ordinary POST is noted, so * that if no Spam Eater check ran for it, the button can say so. */ public function watch_bot_test() { if ( ! $this->is_bot_test() ) { return; } add_action( 'shutdown', function () { if ( ! $this->checked ) { $this->save_bot_test_result( 'unchecked' ); } }, 0 ); } /** * AJAX: the latest bot test result for the current administrator. */ public function ajax_bot_test_result() { check_ajax_referer( 'accessnow_spam_eater_bot_test_result', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $since = isset( $_POST['since'] ) ? (float) sanitize_text_field( wp_unslash( $_POST['since'] ) ) : 0.0; $result = get_transient( 'accessnow_spam_eater_bot_test_' . get_current_user_id() ); if ( ! is_array( $result ) || ! isset( $result['time'] ) || (float) $result['time'] <= $since ) { wp_send_json_success( array( 'status' => 'waiting', 'time' => is_array( $result ) && isset( $result['time'] ) ? (float) $result['time'] : 0, ) ); } $code = (string) $result['code']; if ( 'unchecked' === $code ) { $status = 'unchecked'; $message = __( 'The test reached your site, but Spam Eater did not check it. This form is not protected: check that it is ticked under Settings → Spam Eater.', 'accessnow-spam-eater' ); } elseif ( '' === $code ) { $status = 'passed'; $message = __( 'Spam Eater checked the test and let it through. This should not happen: please get in touch with support.', 'accessnow-spam-eater' ); } else { $status = 'refused'; /* translators: %s: reference code, such as SE-B1. */ $message = sprintf( __( 'Spam Eater refused the test with reference %s. This form is protected. The test was not counted in the statistics and nothing was sent.', 'accessnow-spam-eater' ), $code ); } wp_send_json_success( array( 'status' => $status, 'code' => $code, 'message' => $message, 'time' => (float) $result['time'], ) ); } /** * Settings for the "Test as a bot" button, added to the front-end script's config * for administrators only. * * @return array */ private function get_bot_test_config() { if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) || ! $this->is_bot_test_button_on() ) { return array(); } return array( 'nonce' => wp_create_nonce( 'accessnow_spam_eater_bot_test' ), 'resultNonce' => wp_create_nonce( 'accessnow_spam_eater_bot_test_result' ), 'resultAction' => 'accessnow_spam_eater_bot_test_result', 'targets' => '.wp-block-woocommerce-checkout', 'text' => array( 'button' => __( 'Test this form as a bot', 'accessnow-spam-eater' ), 'stop' => __( 'Stop the bot test', 'accessnow-spam-eater' ), 'on' => __( 'Bot test is on for this form (only you see this). Fill it in and send it: Spam Eater fills in the hidden field and leaves out the script’s values, as a bot would, and should refuse it. If the form is not protected, it is sent like any other, so use test details.', 'accessnow-spam-eater' ), 'waiting' => __( 'Waiting for the result…', 'accessnow-spam-eater' ), 'none' => __( 'No result yet. If the form showed an error or a thank-you message, Spam Eater may not have seen the submission: check that this form is ticked under Settings → Spam Eater.', 'accessnow-spam-eater' ), ), ); } /** * Whether administrators see the "Test as a bot" button (on unless turned off). * * @return bool */ private function is_bot_test_button_on() { $settings = $this->get_settings(); return ! isset( $settings['bot_test_button'] ) || ! empty( $settings['bot_test_button'] ); } // ===================================================================== // Daily statistics (counts only) // ===================================================================== /** * Add one to today's count for a reason. Only counts are kept: no address, page or content. * * @param string $reason Reason key. */ private function record_daily_stat( $reason ) { $stats = get_option( 'accessnow_spam_eater_daily_stats', array() ); $stats = is_array( $stats ) ? $stats : array(); $day = wp_date( 'Y-m-d' ); if ( ! isset( $stats[ $day ] ) || ! is_array( $stats[ $day ] ) ) { $stats[ $day ] = array(); } $stats[ $day ][ $reason ] = ( isset( $stats[ $day ][ $reason ] ) ? (int) $stats[ $day ][ $reason ] : 0 ) + 1; ksort( $stats ); $stats = array_slice( $stats, -self::STATS_DAYS, null, true ); update_option( 'accessnow_spam_eater_daily_stats', $stats, false ); } /** * Daily counts for the last $days days (today included), oldest first, with * every day present. * * @param int $days Number of days (1 to 35). * @return array> 'Y-m-d' => reason => count. */ public function get_daily_stats( $days = 30 ) { $days = max( 1, min( self::STATS_DAYS, (int) $days ) ); $stored = get_option( 'accessnow_spam_eater_daily_stats', array() ); $stored = is_array( $stored ) ? $stored : array(); $out = array(); $today = new \DateTimeImmutable( 'now', wp_timezone() ); for ( $i = $days - 1; $i >= 0; $i-- ) { $day = $today->modify( '-' . $i . ' days' )->format( 'Y-m-d' ); $out[ $day ] = ( isset( $stored[ $day ] ) && is_array( $stored[ $day ] ) ) ? array_map( 'intval', $stored[ $day ] ) : array(); } return $out; } /** * Translated label for a reason key. * * @param string $reason Reason key. * @return string */ public function reason_label( $reason ) { return $this->get_reason_label( $reason ); } /** * Total blocked submissions since the plugin was installed. * * @return int */ public function get_blocked_total() { return (int) get_option( 'accessnow_spam_eater_blocked_count', 0 ); } // ===================================================================== // Slow down password guessing on the login form // ===================================================================== /** * Whether the login throttle is on (off unless turned on). * * @return bool */ private function is_login_throttle_on() { return $this->is_setting_on( 'login_throttle' ); } /** * Keyed hash of the address and username a login attempt came from. Neither is stored. * * @param string $ip Normalised IP. * @param string $username Username or email address as typed. * @return string */ private function throttle_key( $ip, $username ) { $username = strtolower( trim( (string) $username ) ); return substr( hash_hmac( 'sha256', 'spam-eater-login|' . $ip . '|' . $username, wp_salt( 'auth' ) ), 0, 32 ); } /** * Failed logins allowed before the first wait. * * @return int */ private function throttle_free_attempts() { /** * Filters how many failed logins in a row, from one address for one username, * are allowed before the next attempt has to wait. * * @param int $attempts Default 5. */ return max( 1, (int) apply_filters( 'accessnow_spam_eater_login_free_attempts', 5 ) ); } /** * Wait, in seconds, after a number of failed logins: none for the first few, then * 30 seconds, doubling with each failure, never more than the maximum (15 minutes). * * @param int $failures Failed logins in a row. * @return int */ private function throttle_wait( $failures ) { $over = (int) $failures - $this->throttle_free_attempts(); if ( $over < 0 ) { return 0; } /** * Filters the longest a login attempt ever has to wait, in seconds. * * @param int $max Default 900 (15 minutes). At most one hour. */ $max = min( HOUR_IN_SECONDS, max( 30, (int) apply_filters( 'accessnow_spam_eater_login_max_wait', 15 * MINUTE_IN_SECONDS ) ) ); return (int) min( $max, 30 * pow( 2, min( $over, 10 ) ) ); } /** * The stored failure counters, without expired ones. Each entry is keyed by a * keyed hash and holds only a count and two times. * * @return array */ private function get_throttle() { $entries = get_option( 'accessnow_spam_eater_login_throttle', array() ); $entries = is_array( $entries ) ? $entries : array(); $now = time(); foreach ( $entries as $key => $entry ) { if ( ! is_array( $entry ) || ( $now - (int) $entry['last'] ) > HOUR_IN_SECONDS && (int) $entry['until'] < $now ) { unset( $entries[ $key ] ); } } return $entries; } /** * Save the failure counters (at most 500, newest kept). Deleted when empty. * * @param array $entries Counters. */ private function save_throttle( $entries ) { if ( empty( $entries ) ) { delete_option( 'accessnow_spam_eater_login_throttle' ); return; } if ( count( $entries ) > self::BLOCKLIST_MAX ) { $entries = array_slice( $entries, -self::BLOCKLIST_MAX, null, true ); } update_option( 'accessnow_spam_eater_login_throttle', $entries, false ); } /** * The username a login attempt is for, from the authenticate filter or the form. * * @param string $username Username passed to the filter. * @return string */ private function login_name( $username ) { if ( '' === (string) $username ) { $username = sanitize_user( (string) $this->get_posted( 'log' ) ); } return (string) $username; } /** * Filter: authenticate (priority 30, after the password check). While this address * has to wait before trying this username again, the attempt is refused, even with * the right password, and told how long to wait. An address on the allow list, and * any other address or username, is not affected. * * @param \WP_User|\WP_Error|null $user Result so far. * @param string $username Username or email address. * @return \WP_User|\WP_Error|null */ public function throttle_login( $user, $username ) { $username = $this->login_name( $username ); $ip = $this->get_client_ip(); if ( '' === $username || '' === $ip || $this->is_ip_allowlisted( $ip ) ) { return $user; } $entries = $this->get_throttle(); $key = $this->throttle_key( $ip, $username ); $wait = isset( $entries[ $key ] ) ? (int) $entries[ $key ]['until'] - time() : 0; if ( $wait <= 0 ) { return $user; } $this->throttled = true; $minutes = max( 1, (int) ceil( $wait / MINUTE_IN_SECONDS ) ); return new \WP_Error( 'accessnow_spam_eater_throttled', sprintf( /* translators: 1: number of minutes, 2: reference code. */ _n( 'Error: Too many failed attempts to log in with this username. Please wait %1$s minute and try again, or reset your password. [Ref: %2$s]', 'Error: Too many failed attempts to log in with this username. Please wait %1$s minutes and try again, or reset your password. [Ref: %2$s]', $minutes, 'accessnow-spam-eater' ), number_format_i18n( $minutes ), 'SE-T9' ) ); } /** * Action: wp_login_failed. Count a failed login for this address and username, and * start a wait once the free attempts are used up. Attempts refused while waiting * don't count, so the wait never grows while nobody can try. * * @param string $username Username or email address. */ public function count_failed_login( $username ) { $ip = $this->get_client_ip(); if ( $this->throttled || '' === (string) $username || '' === $ip || $this->is_ip_allowlisted( $ip ) ) { return; } $now = time(); $entries = $this->get_throttle(); $key = $this->throttle_key( $ip, $username ); $entry = isset( $entries[ $key ] ) ? $entries[ $key ] : array( 'count' => 0, 'until' => 0, 'last' => 0, ); unset( $entries[ $key ] ); $entry['count'] = (int) $entry['count'] + 1; $entry['last'] = $now; $wait = $this->throttle_wait( $entry['count'] ); if ( $wait > 0 ) { $entry['until'] = $now + $wait; if ( $entry['count'] === $this->throttle_free_attempts() ) { $this->record_daily_stat( 'login_throttled' ); } } $entries[ $key ] = $entry; $this->save_throttle( $entries ); } /** * Action: wp_login. A successful login clears the counter for this address and * username, so the owner never carries old failures around. * * @param string $user_login Username. * @param \WP_User $user User. */ public function clear_login_throttle( $user_login, $user = null ) { $ip = $this->get_client_ip(); if ( '' === $ip ) { return; } $entries = $this->get_throttle(); $names = array( $user_login, $this->login_name( '' ) ); if ( $user instanceof \WP_User ) { $names[] = $user->user_email; } $changed = false; foreach ( array_unique( array_filter( $names ) ) as $name ) { $key = $this->throttle_key( $ip, $name ); if ( isset( $entries[ $key ] ) ) { unset( $entries[ $key ] ); $changed = true; } } if ( $changed ) { $this->save_throttle( $entries ); } } /** * Number of address and username pairs that currently have to wait. * * @return int */ public function count_throttled_logins() { $now = time(); return count( array_filter( $this->get_throttle(), function ( $entry ) use ( $now ) { return (int) $entry['until'] > $now; } ) ); } /** * Clear every login wait (from WP-CLI or the settings page). */ public function clear_all_login_throttles() { delete_option( 'accessnow_spam_eater_login_throttle' ); } /** * Return the opaque failure code from the last is_spam() check. */ private function get_spam_fail_code() { return $this->spam_fail_code; } private function spam_die() { wp_die( esc_html__( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ), esc_html__( 'Spam Detected', 'accessnow-spam-eater' ), array( 'response' => 403, 'back_link' => true, ) ); } // ===================================================================== // IP Blocking // ===================================================================== /** * Block duration in seconds. * * @return int */ private function block_duration() { $settings = $this->get_settings(); $hours = isset( $settings['ip_block_duration'] ) ? (int) $settings['ip_block_duration'] : 24; return max( 1, $hours ) * HOUR_IN_SECONDS; } private function is_ip_blocked( $ip ) { if ( empty( $ip ) ) { return false; } $blocked = $this->get_blocked_ips(); $key = $this->ip_key( $ip ); if ( empty( $blocked[ $key ]['blocked_at'] ) ) { return false; } if ( ( time() - (int) $blocked[ $key ]['blocked_at'] ) > $this->block_duration() ) { unset( $blocked[ $key ] ); $this->save_blocked_ips( $blocked ); return false; } return true; } /** * Drop expired blocks and counters that have not been seen within the block duration. * * @param array $blocked Block list. * @param int $now Current time. * @return array */ private function prune_blocked_ips( $blocked, $now ) { $window = $this->block_duration(); foreach ( $blocked as $key => $entry ) { $ref = ! empty( $entry['blocked_at'] ) ? (int) $entry['blocked_at'] : ( isset( $entry['last_seen'] ) ? (int) $entry['last_seen'] : 0 ); if ( ( $now - $ref ) > $window ) { unset( $blocked[ $key ] ); } } return $blocked; } private function maybe_block_ip( $ip ) { if ( empty( $ip ) ) { return; } $settings = $this->get_settings(); $threshold = isset( $settings['ip_block_threshold'] ) ? (int) $settings['ip_block_threshold'] : 5; if ( $threshold <= 0 ) { return; } $now = time(); $blocked = $this->prune_blocked_ips( $this->get_blocked_ips(), $now ); $key = $this->ip_key( $ip ); $entry = isset( $blocked[ $key ] ) ? $blocked[ $key ] : array( 'count' => 0, 'blocked_at' => 0, 'label' => wp_privacy_anonymize_ip( $ip ), ); unset( $blocked[ $key ] ); // Re-add at the end so the cap keeps the most recent entries. $entry['count'] = (int) $entry['count'] + 1; $entry['last_seen'] = $now; if ( $entry['count'] >= $threshold && empty( $entry['blocked_at'] ) ) { $entry['blocked_at'] = $now; } $blocked[ $key ] = $entry; if ( count( $blocked ) > self::BLOCKLIST_MAX ) { $blocked = array_slice( $blocked, -self::BLOCKLIST_MAX, null, true ); } $this->save_blocked_ips( $blocked ); } public function ajax_unblock_ip() { check_ajax_referer( 'accessnow_spam_eater_unblock', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $key = isset( $_POST['key'] ) ? sanitize_key( wp_unslash( $_POST['key'] ) ) : ''; if ( ! preg_match( '/^[a-f0-9]{32}$/', $key ) ) { wp_send_json_error( 'Invalid entry', 400 ); } $blocked = $this->get_blocked_ips(); unset( $blocked[ $key ] ); $this->save_blocked_ips( $blocked ); wp_send_json_success(); } // ===================================================================== // Logging (minimal and opt-in) // ===================================================================== /** * Whether the optional spam log is turned on (off by default). * * @return bool */ private function is_log_enabled() { $settings = $this->get_settings(); return ! empty( $settings['enable_log'] ); } /** * Record a blocked submission: bump the counter, feed the IP block list and, * only when the log is turned on, store a minimal log entry. Form contents * are never stored and the IP is anonymised. * * @param string $reason Reason key. */ private function record_block( $reason ) { $ip = $this->get_client_ip(); $count = (int) get_option( 'accessnow_spam_eater_blocked_count', 0 ); update_option( 'accessnow_spam_eater_blocked_count', $count + 1, false ); $this->record_daily_stat( $reason ); if ( $this->is_log_enabled() ) { $log = $this->prune_log( get_option( 'accessnow_spam_eater_log', array() ) ); $log[] = array( 'time' => time(), 'ip' => '' !== $ip ? wp_privacy_anonymize_ip( $ip ) : '', 'reason' => $reason, 'page' => $this->get_request_path(), ); update_option( 'accessnow_spam_eater_log', array_slice( $log, -self::LOG_MAX_ENTRIES ), false ); } // Only a filled honeypot is a sure sign of a bot. The other checks can fail for real // people (an old browser, a gateway that posts its own data, rotated salts), and // counting those would block customers who simply tried again. $counts = ( 'filled_honeypot' === $reason ); /** * Filters whether a blocked submission counts towards blocking its IP address. * * @param bool $counts Whether it counts. By default only a filled honeypot does. * @param string $reason Reason key, e.g. 'filled_honeypot' or 'missing_js_token'. */ if ( apply_filters( 'accessnow_spam_eater_counts_toward_block', $counts, $reason ) ) { $this->maybe_block_ip( $ip ); } } /** * Remove log entries older than the retention period (and any old-format entries). * * @param mixed $log Stored log. * @return array */ private function prune_log( $log ) { if ( ! is_array( $log ) ) { return array(); } $cutoff = time() - self::LOG_MAX_AGE; $kept = array(); foreach ( $log as $entry ) { if ( is_array( $entry ) && isset( $entry['time'] ) && is_int( $entry['time'] ) && $entry['time'] >= $cutoff && ! isset( $entry['post_data'] ) ) { $kept[] = $entry; } } return $kept; } /** * Path of the current request, without the query string (which can hold personal data). * * @return string */ private function get_request_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $path = wp_parse_url( $uri, PHP_URL_PATH ); return is_string( $path ) ? substr( $path, 0, 200 ) : ''; } /** * Delete the log when logging is turned off, clear login waits when the login throttle * is turned off, and schedule or unschedule the weekly summary. * * @param mixed $old_value Previous settings. * @param mixed $new_value New settings. */ public function on_settings_updated( $old_value, $new_value ) { $this->cached_settings = null; $this->content = null; if ( ! is_array( $new_value ) || empty( $new_value['enable_log'] ) ) { delete_option( 'accessnow_spam_eater_log' ); } if ( ! is_array( $new_value ) || empty( $new_value['login_throttle'] ) ) { $this->clear_all_login_throttles(); } Dashboard::schedule_weekly_email( is_array( $new_value ) && ! empty( $new_value['weekly_email'] ) ); } /** * Admin action: clear the spam log. */ public function handle_clear_log() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'accessnow-spam-eater' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'accessnow_spam_eater_clear_log' ); delete_option( 'accessnow_spam_eater_log' ); wp_safe_redirect( admin_url( 'options-general.php?page=accessnow-spam-eater' ) ); exit; } private function get_reason_label( $reason ) { $labels = array( 'filled_honeypot' => __( 'Filled honeypot field', 'accessnow-spam-eater' ), 'missing_js_token' => __( 'JavaScript not executed', 'accessnow-spam-eater' ), 'tampered_timestamp' => __( 'Tampered timestamp', 'accessnow-spam-eater' ), 'too_fast' => __( 'Submitted too quickly', 'accessnow-spam-eater' ), 'expired_token' => __( 'Form expired (page too old)', 'accessnow-spam-eater' ), 'no_interaction' => __( 'No mouse/keyboard interaction', 'accessnow-spam-eater' ), 'reused_token' => __( 'Interaction token used too often', 'accessnow-spam-eater' ), 'blocked_ip' => __( 'Blocked IP address', 'accessnow-spam-eater' ), 'blocked_words' => __( 'Matched your own words or patterns', 'accessnow-spam-eater' ), 'disposable_email' => __( 'Throwaway email address', 'accessnow-spam-eater' ), 'login_throttled' => __( 'Login made to wait after failed attempts', 'accessnow-spam-eater' ), ); return isset( $labels[ $reason ] ) ? $labels[ $reason ] : $reason; } // ===================================================================== // Privacy // ===================================================================== /** * Suggested text for the site's privacy policy (Settings → Privacy). */ public function add_privacy_policy_content() { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $content = '

' . esc_html__( 'This site uses Spam Eater to stop automated spam on its forms. When you submit a form, hidden fields and a small script help tell people from bots. Spam Eater does not set cookies, does not track you and does not send any data to other services. It never stores what you type into a form.', 'accessnow-spam-eater' ) . '

'; $content .= '

' . esc_html__( 'If submissions from your IP address are flagged as spam several times, a one-way hash of the address and a shortened (anonymised) version of it are kept for the blocking period set by the site owner (24 hours by default), so that repeat offenders can be blocked. They are then deleted automatically.', 'accessnow-spam-eater' ) . '

'; $content .= '

' . esc_html__( 'If the site owner turns on the optional spam log, each blocked submission records the time, the reason, the page address (without query string) and an anonymised IP address. Log entries are deleted after 30 days.', 'accessnow-spam-eater' ) . '

'; $content .= '

' . esc_html__( 'If the site owner has listed words or patterns to block, or turned on the check for throwaway email addresses, what you type is compared with them in memory as the form arrives. It is not stored or logged, and no email address is sent anywhere to be checked. Only the number of refused submissions per reason and day is kept, without any address or content.', 'accessnow-spam-eater' ) . '

'; $content .= '

' . esc_html__( 'If the site owner turns on the login throttle, a failed login keeps a one-way hash of your IP address and the username you tried, with a count, for up to an hour, so that repeated guessing has to slow down. A successful login removes it.', 'accessnow-spam-eater' ) . '

'; $content .= '

' . esc_html__( 'The site owner may list IP addresses or ranges that are always allowed or always refused. Those addresses are kept as the site owner typed them.', 'accessnow-spam-eater' ) . '

'; wp_add_privacy_policy_content( 'Spam Eater', wp_kses_post( $content ) ); } // ===================================================================== // WordPress Core — Comments // ===================================================================== public function add_honeypot_to_comment_form( $defaults ) { if ( isset( $defaults['comment_field'] ) ) { $defaults['comment_field'] .= $this->render_field( 'comment' ); } return $defaults; } public function check_comment_honeypot( $commentdata ) { if ( current_user_can( 'moderate_comments' ) && ! $this->is_bot_test() ) { return $commentdata; } // wpDiscuz draws its own form, which only carries the fields when its integration is on. $wpdiscuz = $this->is_wpdiscuz_request(); if ( $wpdiscuz && ! $this->is_on( 'wpdiscuz' ) ) { return $commentdata; } if ( ! $wpdiscuz && ! $this->is_on( 'wp_comments' ) ) { return $commentdata; } // Pingbacks, trackbacks and XML-RPC comments never come from the comment form, // so they can't carry the hidden fields. WordPress's own moderation handles them. $type = isset( $commentdata['comment_type'] ) ? $commentdata['comment_type'] : ''; if ( in_array( $type, array( 'pingback', 'trackback' ), true ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { return $commentdata; } // Some plugins run their own forms through this filter to borrow Akismet (Clean and Simple // Contact Form sends 'contact-form'). Those forms don't carry the comment form's fields, so // checking them here refused every message, real ones included. Only comments and reviews. if ( ! in_array( $type, $this->comment_types(), true ) ) { return $commentdata; } if ( $this->is_spam() ) { if ( $wpdiscuz ) { // wpDiscuz shows this message; an error page would only reach its console. wp_send_json_error( array( 'error' => $this->spam_message() ) ); } $this->spam_die(); } return $commentdata; } /** * Comment types the comment form check applies to. * * @return string[] */ private function comment_types() { /** * Filters the comment types Spam Eater checks on preprocess_comment. * * @param string[] $types Comment types. '' and 'comment' are ordinary comments, * 'review' is a WooCommerce product review. */ return (array) apply_filters( 'accessnow_spam_eater_comment_types', array( '', 'comment', 'review' ) ); } // ===================================================================== // WordPress Core — Login // ===================================================================== public function add_honeypot_to_wp_login() { $this->print_field( 'wplogin' ); } public function check_wp_login_honeypot( $user, $username, $password ) { if ( empty( $username ) && empty( $password ) ) { return $user; } if ( $this->is_spam( 'login' ) ) { $code = $this->get_spam_fail_code(); /* translators: %s: internal reference code */ return new \WP_Error( 'spam_eater_spam', sprintf( __( 'Error: Your submission has been flagged as spam. [Ref: %s]', 'accessnow-spam-eater' ), esc_html( $code ) ) ); } return $user; } // ===================================================================== // WordPress Core — Registration // ===================================================================== public function add_honeypot_to_wp_register() { $this->print_field( 'wpregister' ); } public function check_wp_register_honeypot( $errors, $sanitized_user_login, $user_email ) { if ( $this->is_spam() ) { $errors->add( 'spam_eater_spam', __( 'Error: Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } return $errors; } // ===================================================================== // WordPress Core — Lost Password // ===================================================================== public function add_honeypot_to_wp_lostpassword() { $this->print_field( 'wplostpw' ); } public function check_wp_lostpassword_honeypot( $errors ) { // An administrator sending a reset link from Users (or its row action) isn't using the form. if ( current_user_can( 'edit_users' ) && ! $this->is_bot_test() ) { return; } if ( $this->is_spam() ) { $errors->add( 'spam_eater_spam', __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } } // ===================================================================== // WooCommerce — Checkout // ===================================================================== public function add_honeypot_to_checkout( $checkout ) { $this->print_field( 'woocheckout' ); } public function check_checkout_honeypot() { $context = $this->is_checkout_form_request() ? 'full' : 'basic'; if ( $this->is_spam( $context ) ) { /* translators: %s: internal reference code */ wc_add_notice( sprintf( __( 'Your order could not be processed. Please try again. [Ref: %s]', 'accessnow-spam-eater' ), esc_html( $this->get_spam_fail_code() ) ), 'error' ); } } /** * Whether this order comes from WooCommerce's own checkout form, which carries the * hidden fields. Express payment buttons (Apple Pay, Google Pay) in some gateways * place the order through their own endpoint with their own data, which can't. * * @return bool */ private function is_checkout_form_request() { $endpoint = sanitize_key( (string) get_query_var( 'wc-ajax' ) ); if ( '' === $endpoint ) { // The form posted without JavaScript, or a gateway's admin-ajax or REST handler. $is_form = ! wp_doing_ajax() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ); } else { $is_form = ( 'checkout' === $endpoint ); } /** * Filters whether a checkout request came from the checkout form and gets every check. * Other requests get only the IP block list and honeypot checks. * * @param bool $is_form Whether the request came from the checkout form. * @param string $endpoint The wc-ajax endpoint, or '' when there is none. */ return (bool) apply_filters( 'accessnow_spam_eater_is_checkout_form_request', $is_form, $endpoint ); } // ===================================================================== // WooCommerce — My Account Login // ===================================================================== public function add_honeypot_to_woo_login() { $this->print_field( 'woologin' ); } public function check_woo_login_honeypot( $validation_error ) { if ( $this->is_spam( 'login' ) ) { $code = $this->get_spam_fail_code(); /* translators: %s: internal reference code */ $validation_error->add( 'spam_eater_spam', sprintf( __( 'Your submission has been flagged as spam. [Ref: %s]', 'accessnow-spam-eater' ), esc_html( $code ) ) ); } return $validation_error; } // ===================================================================== // WooCommerce — My Account Registration // ===================================================================== public function add_honeypot_to_woo_register() { $this->print_field( 'wooregister' ); } public function check_woo_register_honeypot( $validation_error ) { if ( $this->is_spam() ) { $validation_error->add( 'spam_eater_spam', __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } return $validation_error; } // ===================================================================== // Contact Form 7 // ===================================================================== public function add_honeypot_to_cf7( $form ) { $honeypot = $this->render_field( 'cf7' ); $form = preg_replace_callback( '/]*type=["\']submit["\'][^>]*>/', function ( $m ) use ( $honeypot ) { return $honeypot . $m[0]; }, $form, 1 ); if ( false === strpos( $form, 'se-wrap' ) ) { $form .= $honeypot; } return $form; } public function check_cf7_honeypot( $result, $tags ) { if ( $this->is_spam() ) { $result->invalidate( new \WPCF7_FormTag( array( 'name' => 'honeypot' ) ), __( 'Your message could not be sent.', 'accessnow-spam-eater' ) ); } return $result; } // ===================================================================== // Gravity Forms // ===================================================================== public function add_honeypot_to_gravity_forms( $button, $form ) { return $this->render_field( 'gf-' . $form['id'] ) . $button; } public function check_gravity_forms_honeypot( $validation_result ) { if ( $this->is_spam() ) { $validation_result['is_valid'] = false; if ( ! empty( $validation_result['form']['fields'] ) ) { $validation_result['form']['fields'][0]->failed_validation = true; $validation_result['form']['fields'][0]->validation_message = __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ); } } return $validation_result; } // ===================================================================== // WPForms // ===================================================================== public function add_honeypot_to_wpforms( $form_data ) { $form_id = isset( $form_data['id'] ) ? $form_data['id'] : 0; $this->print_field( 'wpf-' . $form_id ); } public function check_wpforms_honeypot( $errors, $form_data ) { if ( $this->is_spam() ) { $form_id = isset( $form_data['id'] ) ? $form_data['id'] : 0; $errors[ $form_id ] = array( 'header' => __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } return $errors; } // ===================================================================== // Formidable Forms // ===================================================================== public function add_honeypot_to_formidable( $form ) { $form_id = is_object( $form ) ? $form->id : 0; $this->print_field( 'frm-' . $form_id ); } public function check_formidable_honeypot( $errors, $values ) { if ( $this->is_spam() ) { $errors['spam_eater'] = __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ); } return $errors; } // ===================================================================== // Fluent Forms // ===================================================================== public function add_honeypot_to_fluent_forms( $form ) { $form_id = is_object( $form ) && isset( $form->id ) ? $form->id : 0; $this->print_field( 'ff-' . $form_id ); } public function check_fluent_forms_honeypot( $errors, $form_data, $form, $fields ) { if ( $this->is_spam() ) { $errors['spam_eater'] = array( __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } return $errors; } // ===================================================================== // bbPress // ===================================================================== public function add_honeypot_to_bbpress() { $this->print_field( 'bbp' ); } public function check_bbpress_honeypot() { if ( $this->is_spam() ) { bbp_add_error( 'spam_eater_spam', __( 'Error: Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } } // ===================================================================== // Elementor Pro Forms (single injection via content filter only) // ===================================================================== public function add_honeypot_to_elementor_output( $content, $widget ) { if ( 'form' !== $widget->get_name() ) { return $content; } $honeypot = $this->render_field( 'elem-' . $widget->get_id() ); return str_replace( '', $honeypot . '', $content ); } public function check_elementor_honeypot( $record, $ajax_handler ) { if ( $this->is_spam() ) { $ajax_handler->add_error( 'spam_eater', __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } } // ===================================================================== // Ninja Forms // ===================================================================== public function add_honeypot_to_ninja_forms( $form_id ) { $this->print_field( 'nf-' . $form_id ); } public function check_ninja_forms_honeypot( $form_data ) { if ( $this->is_spam() ) { $form_data['errors']['form']['spam_eater'] = __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ); } return $form_data; } // ===================================================================== // Forminator // ===================================================================== public function add_honeypot_to_forminator( $html, $button ) { return $this->render_field( 'fmtr' ) . $html; } public function check_forminator_honeypot( $submit_errors, $form_id, $field_data_array ) { if ( $this->is_spam() ) { $submit_errors[]['spam_eater'] = __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ); } return $submit_errors; } // ===================================================================== // HTML Forms // ===================================================================== public function add_honeypot_to_html_forms( $html, $form ) { $honeypot = $this->render_field( 'hf' ); return str_replace( '', $honeypot . '', $html ); } public function check_html_forms_honeypot( $error_code, $form, $data ) { if ( $this->is_spam() ) { return 'spam_eater_blocked'; } return $error_code; } // ===================================================================== // BuddyPress / BuddyBoss / Youzify // ===================================================================== public function add_honeypot_to_buddypress() { $this->print_field( 'bp' ); } public function check_buddypress_honeypot() { if ( $this->is_spam() && function_exists( 'buddypress' ) ) { $buddypress = buddypress(); if ( isset( $buddypress->signup ) ) { $buddypress->signup->errors['spam_eater'] = __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ); } } } // ===================================================================== // Ultimate Member // ===================================================================== public function add_honeypot_to_ultimate_member( $args ) { $this->print_field( 'um' ); } public function check_ultimate_member_honeypot( $submitted_data ) { if ( $this->is_spam() ) { UM()->form()->add_error( 'spam_eater', __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } } // ===================================================================== // UsersWP // ===================================================================== public function add_honeypot_to_userswp( $form_type, $args = array() ) { if ( in_array( $form_type, array( 'register', 'login' ), true ) ) { $this->print_field( 'uwp-' . $form_type ); } } public function check_userswp_honeypot( $errors, $data, $type ) { $context = ( 'login' === $type ) ? 'login' : 'full'; if ( in_array( $type, array( 'register', 'login' ), true ) && $this->is_spam( $context ) ) { $code = $this->get_spam_fail_code(); /* translators: %s: internal reference code */ $errors->add( 'spam_eater', sprintf( __( 'Your submission has been flagged as spam. [Ref: %s]', 'accessnow-spam-eater' ), esc_html( $code ) ) ); } return $errors; } // ===================================================================== // MC4WP Mailchimp // ===================================================================== public function add_honeypot_to_mc4wp( $content, $form, $element ) { return $content . $this->render_field( 'mc4wp' ); } public function check_mc4wp_honeypot( $errors, $form ) { if ( $this->is_spam() ) { $errors[] = 'spam_eater_blocked'; } return $errors; } // ===================================================================== // Easy Digital Downloads // ===================================================================== public function add_honeypot_to_edd() { $this->print_field( 'edd' ); } public function check_edd_honeypot( $valid_data, $data ) { if ( $this->is_spam() ) { edd_set_error( 'spam_eater', __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ); } } // ===================================================================== // Bricks Builder // ===================================================================== public function add_honeypot_to_bricks( $content, $post ) { if ( false !== strpos( $content, 'bricks-form' ) ) { $honeypot = $this->render_field( 'bricks' ); $content = str_replace( '', $honeypot . '', $content ); } return $content; } public function check_bricks_honeypot( $errors, $form ) { if ( $this->is_spam() ) { $errors[] = __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ); } return $errors; } // ===================================================================== // Beaver Builder // ===================================================================== public function add_honeypot_to_beaver( $content, $module ) { if ( 'contact-form' === $module->slug ) { $honeypot = $this->render_field( 'bb' ); $content = str_replace( '', $honeypot . '', $content ); } return $content; } public function check_beaver_honeypot() { if ( $this->is_spam() ) { wp_send_json_error( array( 'message' => __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ) ) ); } } // ===================================================================== // QuForm // ===================================================================== public function add_honeypot_to_quform( $form ) { $this->print_field( 'qf' ); } public function check_quform_honeypot( $result, $form ) { if ( $this->is_spam() ) { return array( 'type' => 'error', 'error' => array( 'enabled' => true, 'title' => __( 'Error', 'accessnow-spam-eater' ), 'content' => __( 'Your submission has been flagged as spam.', 'accessnow-spam-eater' ), ), ); } return $result; } // ===================================================================== // Clean and Simple Contact Form // ===================================================================== /** * The form has no hook inside it, so the fields go into the shortcode's output. Its * [contact-form] tag is also Jetpack's, hence the check for the form's own id. * * @param string $output Shortcode output. * @param string $tag Shortcode tag. * @return string */ public function add_honeypot_to_cscf( $output, $tag ) { if ( ! in_array( $tag, array( 'cscf-contact-form', 'contact-form' ), true ) || ! is_string( $output ) || false === strpos( $output, 'id="frmCSCF"' ) ) { return $output; } $pos = strrpos( $output, '' ); if ( false === $pos ) { return $output; } return substr_replace( $output, $this->render_field( 'cscf' ) . '', $pos, strlen( '' ) ); } /** * Runs inside the form's SendMail(). Its REST route needs a logged-in user, so only the * visitor form is checked. On spam the form drops the message as it does for Akismet spam; * its AJAX submit gets "message not sent" instead, so a real person knows to try again. * * @param object $contact The form's cscf_Contact. * @return object */ public function check_cscf_honeypot( $contact ) { if ( ! is_object( $contact ) || ! empty( $contact->IsRestApi ) ) { return $contact; } if ( $this->is_spam() ) { if ( wp_doing_ajax() ) { wp_send_json( array( 'sent' => false, 'valid' => true, 'errorlist' => array(), ) ); } $contact->IsSpam = true; } return $contact; } // ===================================================================== // WooCommerce — Block checkout (Store API) // ===================================================================== /** * Action: woocommerce_blocks_loaded. Let the checkout request carry Spam Eater's * values under extensions.accessnow_spam_eater (Store API drops unknown namespaces). */ public function register_store_api_data() { if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) { return; } $string = array( 'type' => array( 'string', 'null' ), 'context' => array(), 'readonly' => true, ); woocommerce_store_api_register_endpoint_data( array( 'endpoint' => 'checkout', 'namespace' => 'accessnow_spam_eater', 'schema_callback' => function () use ( $string ) { return array( 'hp' => $string, 'js' => $string, 'ts' => $string, 'act' => $string, 'test' => $string, ); }, 'schema_type' => ARRAY_A, ) ); } /** * Action: woocommerce_store_api_checkout_update_customer_from_request. Check an order * placed through the Store API (the Checkout block, its express payment buttons, or a * card-testing bot posting to /wc/store/v1/checkout) before any order or account is * created. The script adds its values to the request (see assets/js/store-api.js); a * request without them is refused like a form without the script's value. * * @param \WC_Customer $customer Customer. * @param \WP_REST_Request $request Checkout request. * @throws \Automattic\WooCommerce\StoreApi\Exceptions\RouteException When it is spam. */ public function check_store_api_checkout( $customer, $request ) { if ( ! $request instanceof \WP_REST_Request || 'POST' !== $request->get_method() || '/wc/store/v1/checkout' !== untrailingslashit( $request->get_route() ) ) { return; } $values = array(); $ext = $request->get_param( 'extensions' ); if ( is_array( $ext ) && ! empty( $ext['accessnow_spam_eater'] ) && is_array( $ext['accessnow_spam_eater'] ) ) { $values = $ext['accessnow_spam_eater']; } else { // The same values in a header, for flows that send their own body through wp.apiFetch. $header = json_decode( (string) $request->get_header( 'x_accessnow_spam_eater' ), true ); $values = is_array( $header ) ? $header : array(); } $pick = function ( $key ) use ( $values ) { return ( isset( $values[ $key ] ) && is_scalar( $values[ $key ] ) ) ? (string) $values[ $key ] : ''; }; $data = array( $this->get_field_name() => $pick( 'hp' ), 'se_js' => $pick( 'js' ), 'se_ts' => $pick( 'ts' ), 'se_act' => $pick( 'act' ), 'se_test' => $pick( 'test' ), ); // What the customer typed, for the content checks (in memory only). foreach ( array( 'billing_address', 'shipping_address', 'customer_note', 'additional_fields' ) as $key ) { $value = $request->get_param( $key ); if ( null !== $value ) { $data[ $key ] = $value; } } /** * Filters which checks a Store API checkout request gets: 'full' (default) or * 'basic' (block list and honeypot only), for example for a payment gateway whose * express button posts to the Store API with its own data. * * @param string $context 'full' or 'basic'. * @param \WP_REST_Request $request Checkout request. */ $context = (string) apply_filters( 'accessnow_spam_eater_store_api_context', 'full', $request ); $this->request_data = $data; $spam = $this->is_spam( 'basic' === $context ? 'basic' : 'full' ); $this->request_data = null; if ( $spam && class_exists( '\Automattic\WooCommerce\StoreApi\Exceptions\RouteException' ) ) { throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'accessnow_spam_eater_spam', /* translators: %s: internal reference code */ esc_html( sprintf( __( 'Your order could not be processed. Please try again. [Ref: %s]', 'accessnow-spam-eater' ), $this->get_spam_fail_code() ) ), 400 ); } } /** * Load the script, and the small Store API helper, on pages that show the Cart or * Checkout block (express payment buttons in the cart place orders too). */ private function maybe_enqueue_store_api() { if ( ! $this->is_on( 'woo_block_checkout' ) || ! function_exists( 'is_checkout' ) ) { return; } $post = get_post(); $blocks = $post && ( has_block( 'woocommerce/checkout', $post ) || has_block( 'woocommerce/cart', $post ) ); if ( ! $blocks && ! is_checkout() && ! is_cart() ) { return; } $this->enqueue_frontend(); wp_enqueue_script( 'accessnow-spam-eater-store-api', plugins_url( 'assets/js/store-api.js', ACCESSNOW_SPAM_EATER_FILE ), array( 'accessnow-spam-eater', 'wp-api-fetch' ), ACCESSNOW_SPAM_EATER_VERSION, true ); } // ===================================================================== // WooCommerce — Pay for Order and Account details // ===================================================================== public function add_honeypot_to_woo_pay_order() { $this->print_field( 'woopay' ); } /** * Action: woocommerce_before_pay_action. An error notice stops the payment: WooCommerce * only takes it while there are no error notices. * * @param \WC_Order $order Order being paid. */ public function check_woo_pay_order( $order ) { if ( $this->is_spam() ) { /* translators: %s: internal reference code */ wc_add_notice( sprintf( __( 'Your order could not be processed. Please try again. [Ref: %s]', 'accessnow-spam-eater' ), esc_html( $this->get_spam_fail_code() ) ), 'error' ); } } public function add_honeypot_to_woo_account_details() { $this->print_field( 'wooaccount' ); } /** * Action: woocommerce_save_account_details_errors. * * @param \WP_Error $errors Errors (by reference). */ public function check_woo_account_details( $errors ) { if ( $errors instanceof \WP_Error && $this->is_spam() ) { $errors->add( 'spam_eater_spam', $this->spam_message() ); } } /** * The standard refusal, with its reference code. * * @return string */ private function spam_message() { /* translators: %s: internal reference code */ return sprintf( __( 'Your submission has been flagged as spam. [Ref: %s]', 'accessnow-spam-eater' ), $this->get_spam_fail_code() ); } /** * Put the hidden fields just before the last of some HTML. * * @param string $html Form HTML. * @param string $context Short name of the form. * @param string $group Optional array name for the fields (see render_field()). * @return string */ private function insert_before_form_end( $html, $context, $group = '' ) { if ( ! is_string( $html ) ) { return $html; } $pos = strripos( $html, '' ); if ( false === $pos ) { return $html; } return substr_replace( $html, $this->render_field( $context, $group ), $pos, 0 ); } // ===================================================================== // Jetpack Forms // ===================================================================== public function add_honeypot_to_jetpack( $html ) { return $this->insert_before_form_end( $html, 'jetpack' ); } /** * Filter: jetpack_contact_form_is_spam. A WP_Error stops the submission and its * message is shown (true would only file it as spam). * * @param bool|\WP_Error $is_spam Result so far. * @return bool|\WP_Error */ public function check_jetpack( $is_spam ) { if ( is_wp_error( $is_spam ) ) { return $is_spam; } if ( $this->is_spam() ) { return new \WP_Error( 'accessnow_spam_eater_spam', $this->spam_message() ); } return $is_spam; } // ===================================================================== // Kadence Blocks forms (Form and Advanced Form blocks) // ===================================================================== public function add_honeypot_to_kadence( $content ) { return $this->insert_before_form_end( $content, 'kadence' ); } /** * Action: wp_ajax(_nopriv)_kb_process_ajax_submit, before Kadence (10). The Form block * has no filter that runs before its email goes out, so the request is answered here. */ public function check_kadence_form() { if ( $this->is_spam() && class_exists( 'KB_Ajax_Form' ) ) { \KB_Ajax_Form::get_instance()->process_bail( $this->spam_message(), 'Spam' ); } } /** * Filter: kadence_blocks_advanced_form_submission_reject. * * @param bool $reject Whether to reject. * @return bool */ public function check_kadence_advanced_form( $reject ) { return $reject || $this->is_spam(); } /** * Filter: kadence_blocks_advanced_form_submission_reject_message. * * @param string $message Message. * @return string */ public function kadence_reject_message( $message ) { return ( '' !== $this->get_spam_fail_code() ) ? $this->spam_message() : $message; } // ===================================================================== // WS Form // ===================================================================== /** * Filter: wsf_shortcode. WS Form draws its fields in the browser and empties the form * first, so the script adds Spam Eater's fields (see get_inject_targets()). This only * makes sure the script is on the page. * * @param string $html Shortcode output. * @return string */ public function enqueue_for_ws_form( $html ) { $this->enqueue_frontend(); return $html; } /** * Filter: wsf_submit_validate. Runs before WS Form saves anything or runs its actions. * * @param array $actions Validation errors so far. * @param string $post_mode 'submit', 'save' (save progress) or 'action'. * @return array */ public function check_ws_form( $actions, $post_mode = 'submit' ) { if ( 'submit' === $post_mode && $this->is_spam() ) { $actions = is_array( $actions ) ? $actions : array(); $actions[] = array( 'action' => 'message', 'message' => $this->spam_message(), ); } return $actions; } // ===================================================================== // JetFormBuilder // ===================================================================== public function add_honeypot_to_jetformbuilder( $html ) { return $html . $this->render_field( 'jfb' ); } /** * Filter: jet-form-builder/request-handler/request. Runs before any form action * (saving the record, sending email). Spam Eater's own fields are taken out. * * @param array $request Submitted values. * @return array * @throws \JFB_Modules\Security\Exceptions\Spam_Exception When it is spam. */ public function check_jetformbuilder( $request ) { if ( $this->is_spam() && class_exists( '\JFB_Modules\Security\Exceptions\Spam_Exception' ) && class_exists( '\Jet_Form_Builder\Form_Messages\Manager' ) ) { throw new \JFB_Modules\Security\Exceptions\Spam_Exception( esc_html( \Jet_Form_Builder\Form_Messages\Manager::dynamic_error( $this->spam_message() ) ) ); } if ( is_array( $request ) ) { unset( $request[ $this->get_field_name() ], $request['se_js'], $request['se_ts'], $request['se_act'], $request['se_test'] ); } return $request; } // ===================================================================== // SureForms // ===================================================================== public function add_honeypot_to_sureforms() { $this->print_field( 'srfm' ); } /** * Filter: srfm_additional_restriction_check. Runs before SureForms' own checks, the * entry and the email. * * @param bool $blocked Whether the submission is blocked. * @return bool */ public function check_sureforms( $blocked ) { return $blocked || $this->is_spam(); } /** * Filter: srfm_additional_restriction_message. * * @param string $message Message. * @return string */ public function sureforms_message( $message ) { return ( '' !== $this->get_spam_fail_code() ) ? $this->spam_message() : $message; } // ===================================================================== // Everest Forms // ===================================================================== public function add_honeypot_to_everest( $form_data ) { $form_id = ( is_array( $form_data ) && isset( $form_data['id'] ) ) ? absint( $form_data['id'] ) : 0; $this->print_field( 'evf-' . $form_id ); } /** * Filter: everest_forms_process_initial_errors. Runs before the entry is saved. * * @param array $errors Errors by form ID. * @param array $form_data Form. * @return array */ public function check_everest( $errors, $form_data ) { if ( $this->is_spam() ) { $form_id = ( is_array( $form_data ) && isset( $form_data['id'] ) ) ? $form_data['id'] : 0; $errors = is_array( $errors ) ? $errors : array(); $errors[ $form_id ]['header'] = $this->spam_message(); } return $errors; } // ===================================================================== // MailPoet // ===================================================================== /** * Filter: mailpoet_form_widget_post_process (shortcode, block and widget forms). MailPoet * only sends the form's data[...] values, so the fields go in that group. Pop-up, slide-in * and fixed-bar forms get them from the script (see get_inject_targets()). * * @param string $html Form HTML. * @return string */ public function add_honeypot_to_mailpoet( $html ) { return $this->insert_before_form_end( $html, 'mailpoet', 'data' ); } /** * Action: mailpoet_subscription_before_subscribe. Throwing stops the subscription and * MailPoet shows the message. After its own CAPTCHA has been passed the CAPTCHA page * posts without the form's fields, so that request is left to MailPoet. * * @throws \MailPoet\UnexpectedValueException When it is spam. */ public function check_mailpoet() { $data = $this->read_post( 'data' ); $data = is_array( $data ) ? $data : array(); if ( ! empty( $data['captcha'] ) || ! empty( $data['captcha_session_id'] ) ) { return; } $this->request_data = $data; $spam = $this->is_spam(); $this->request_data = null; if ( $spam && class_exists( '\MailPoet\UnexpectedValueException' ) ) { throw new \MailPoet\UnexpectedValueException( esc_html( $this->spam_message() ) ); } } // ===================================================================== // Brevo // ===================================================================== /** * Filter: do_shortcode_tag, for [sibwp_form]. * * @param string $output Shortcode output. * @param string $tag Shortcode tag. * @return string */ public function add_honeypot_to_brevo( $output, $tag ) { return 'sibwp_form' === $tag ? $this->insert_before_form_end( $output, 'brevo' ) : $output; } /** * Filter: widget_display_callback. The Brevo widget prints its form with no hook inside * it, so the script adds the fields; this makes sure the script is on the page. * * @param array|false $instance Widget settings. * @param \WP_Widget $widget Widget. * @return array|false */ public function enqueue_for_brevo_widget( $instance, $widget ) { if ( is_object( $widget ) && isset( $widget->id_base ) && false !== strpos( (string) $widget->id_base, 'sib' ) ) { $this->enqueue_frontend(); } return $instance; } /** * Action: init, before Brevo handles its form (10). Brevo has no hooks of its own, so * a refused sign-up is answered here, in the form Brevo's script shows. */ public function check_brevo() { if ( 'subscribe_form_submit' !== sanitize_key( (string) $this->get_posted( 'sib_form_action' ) ) ) { return; } if ( $this->is_spam() ) { wp_send_json( array( 'status' => 'failure', 'msg' => array( 'errorMsg' => $this->spam_message() ), ) ); } } // ===================================================================== // wpDiscuz // ===================================================================== public function add_honeypot_to_wpdiscuz() { $this->print_field( 'wpdiscuz' ); } /** * Filter: wpdiscuz_antispam_field_prefixes. wpDiscuz keeps these fields' values when it * resets the form after a comment, and copies them into inline feedback forms. * * @param string[] $prefixes Field name prefixes. * @return string[] */ public function wpdiscuz_field_prefixes( $prefixes ) { $prefixes = (array) $prefixes; $prefixes[] = 'se_'; return $prefixes; } /** * Whether this request is a wpDiscuz comment. * * @return bool */ private function is_wpdiscuz_request() { $action = sanitize_key( (string) $this->get_posted( 'action' ) ); return in_array( $action, array( 'wpdaddcomment', 'wpdaddinlinecomment' ), true ); } // ===================================================================== // MemberPress // ===================================================================== public function add_honeypot_to_memberpress() { $this->print_field( 'mepr' ); } /** * Filter: mepr-validate-signup. Runs before the user and transaction are created. A * gateway's AJAX step that sends its own data without the form's fields only gets * the block list and honeypot checks, so no real sign-up is refused for that. * * @param array $errors Error messages. * @return array */ public function check_memberpress( $errors ) { $context = ( wp_doing_ajax() && null === $this->read_post( 'se_ts' ) ) ? 'basic' : 'full'; if ( $this->is_spam( $context ) ) { $errors = (array) $errors; $errors[] = $this->spam_message(); } return $errors; } // ===================================================================== // Paid Memberships Pro // ===================================================================== public function add_honeypot_to_pmpro() { $this->print_field( 'pmpro' ); } /** * Filter: pmpro_checkout_checks (PMPro 3.2+) or pmpro_registration_checks (older). * Runs before the user and order are created. * * @param bool $okay Whether the checkout may go ahead. * @return bool */ public function check_pmpro( $okay ) { if ( $okay && $this->is_spam() ) { if ( function_exists( 'pmpro_setMessage' ) ) { pmpro_setMessage( $this->spam_message(), 'pmpro_error' ); } return false; } return $okay; } // ===================================================================== // GiveWP (option-based forms; visual builder forms are not covered) // ===================================================================== public function add_honeypot_to_givewp() { $this->print_field( 'give' ); } /** * Action: give_embed_head, before its scripts print. GiveWP shows option-based forms in * an iframe and removes every script that isn't its own, so Spam Eater's goes back in. */ public function enqueue_for_givewp_embed() { $this->enqueue_frontend(); } /** * Action: give_checkout_error_checks. Runs on both of GiveWP's passes (the AJAX check * and the final POST) before the donation is created. */ public function check_givewp() { if ( $this->is_spam() && function_exists( 'give_set_error' ) ) { give_set_error( 'spam_eater_spam', $this->spam_message() ); } } // ===================================================================== // Public API for custom forms (see includes/api.php) // ===================================================================== /** * Hidden-field HTML for a custom form, or '' when custom forms are not protected. * * @param string $context Short name of the form. * @return string */ public function api_field( $context = '' ) { if ( ! $this->is_on( 'custom_forms' ) ) { return ''; } return $this->render_field( sanitize_key( (string) $context ) ); } /** * Check the current request's submission. * * @param string $context 'full' (default), 'basic' or 'login' — see is_spam(). * @return bool */ public function api_is_spam( $context = 'full' ) { if ( ! $this->is_on( 'custom_forms' ) ) { return false; } return $this->is_spam( (string) $context ); } /** * Reference code of the last failed check in this request, or ''. * * @return string */ public function api_fail_code() { return $this->get_spam_fail_code(); } // ===================================================================== // Helpers for WP-CLI (includes/class-cli.php) // ===================================================================== /** * The settings with defaults filled in. * * @return array */ public function get_all_settings() { return wp_parse_args( $this->get_settings(), $this->get_defaults() ); } /** * Change settings, through the same cleaning as the settings page. * * @param array $changes Key => new value. Lists may be arrays or text, one per line. * @return array|\WP_Error The saved settings, or an error for an unknown key. */ public function update_settings( $changes ) { $current = $this->get_all_settings(); foreach ( array_keys( (array) $changes ) as $key ) { if ( ! array_key_exists( $key, $current ) ) { /* translators: %s: setting name. */ return new \WP_Error( 'unknown_setting', sprintf( __( 'There is no setting called %s.', 'accessnow-spam-eater' ), $key ) ); } } $input = array_merge( $current, (array) $changes ); foreach ( array( 'trusted_proxies', 'ip_allowlist', 'ip_blocklist' ) as $list ) { if ( is_array( $input[ $list ] ) ) { $input[ $list ] = implode( "\n", $input[ $list ] ); } } $clean = $this->sanitize_settings( $input ); update_option( 'accessnow_spam_eater_settings', $clean ); $this->cached_settings = null; $this->content = null; return $clean; } /** * Clean one address or range the way the IP list settings do. * * @param string $value Address or CIDR range. * @return string Normalised value, or '' when invalid. */ public function clean_ip_entry( $value ) { $clean = $this->sanitize_trusted_proxies( (string) $value ); return isset( $clean[0] ) ? $clean[0] : ''; } /** * Whether a string is a valid single IP address. * * @param string $ip Value. * @return string Normalised IP, or ''. */ public function normalise_ip_public( $ip ) { return $this->normalise_ip( $ip ); } /** * The automatic block list: anonymised address, count and times, for entries that are * blocked now or counting towards a block. * * @return array Hash key => entry. */ public function get_block_list() { return $this->prune_blocked_ips( $this->get_blocked_ips(), time() ); } /** * Remove an address from the automatic block list. * * @param string $ip IP address (only its keyed hash is looked up). * @return bool Whether it was on the list. */ public function unblock_ip( $ip ) { $ip = $this->normalise_ip( $ip ); if ( '' === $ip ) { return false; } $blocked = $this->get_blocked_ips(); $key = $this->ip_key( $ip ); if ( ! isset( $blocked[ $key ] ) ) { return false; } unset( $blocked[ $key ] ); $this->save_blocked_ips( $blocked ); return true; } /** * Empty the automatic block list. */ public function clear_block_list() { $this->save_blocked_ips( array() ); } /** * Run the checks against made-up values, for `wp spam-eater check`. Nothing is * recorded: the result says which check a submission like it would fail. * * @param array $data Submitted values (the hidden fields left out, as a bot sends them). * @param string $ip Address to check as. * @param string $context 'full', 'basic' or 'login'. * @return string Reference code, or '' when it would be let through. */ public function dry_run( $data, $ip, $context = 'full' ) { $saved = array( $this->cached_ip, $this->spam_results, $this->spam_fail_code, $this->block_recorded ); $this->cached_ip = $this->normalise_ip( $ip ); $this->spam_results = array(); $this->request_data = (array) $data; // Recorded as already done, so nothing is counted, logged or blocked. $this->block_recorded = true; $this->spam_fail_code = ''; $this->dry_run = true; $spam = $this->run_checks( in_array( $context, array( 'full', 'basic', 'login' ), true ) ? $context : 'full' ); $this->dry_run = false; $code = $spam ? $this->spam_fail_code : ''; $this->request_data = null; list( $this->cached_ip, $this->spam_results, $this->spam_fail_code, $this->block_recorded ) = $saved; return $code; } /** * Valid hidden-field values, as the script would send them, for `wp spam-eater check`. * * @return array */ public function genuine_fields() { $stamp = ( time() - 10 ) . ':' . $this->sign( 'ts|' . ( time() - 10 ) ); return array( $this->get_field_name() => '', 'se_js' => $this->get_js_token(), 'se_ts' => $stamp, 'se_act' => $this->tokens()->actFor( $stamp ), ); } // ===================================================================== // Settings // ===================================================================== public function add_settings_page() { add_options_page( __( 'Spam Eater', 'accessnow-spam-eater' ), __( 'Spam Eater', 'accessnow-spam-eater' ), 'manage_options', 'accessnow-spam-eater', array( $this, 'render_settings_page' ) ); } public function enqueue_admin_assets( $hook_suffix ) { if ( 'settings_page_accessnow-spam-eater' !== $hook_suffix ) { return; } wp_enqueue_script( 'accessnow-spam-eater-admin', plugins_url( 'assets/js/admin.js', ACCESSNOW_SPAM_EATER_FILE ), array(), ACCESSNOW_SPAM_EATER_VERSION, true ); wp_localize_script( 'accessnow-spam-eater-admin', 'accessnowSpamEaterAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'action' => 'accessnow_spam_eater_unblock_ip', 'nonce' => wp_create_nonce( 'accessnow_spam_eater_unblock' ), ) ); } public function register_settings() { register_setting( 'accessnow_spam_eater', 'accessnow_spam_eater_settings', array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => $this->get_defaults(), ) ); } private function get_defaults() { $defaults = array( 'ip_block_threshold' => 5, 'ip_block_duration' => 24, 'enable_log' => 0, 'trusted_proxies' => array(), 'delete_data' => 0, 'load_everywhere' => 0, 'ip_allowlist' => array(), 'ip_blocklist' => array(), 'block_words' => '', 'block_disposable' => 0, 'login_throttle' => 0, 'weekly_email' => 0, 'bot_test_button' => 1, ); foreach ( $this->integrations as $key => $integration ) { $defaults[ $key ] = 1; } return $defaults; } public function sanitize_settings( $input ) { $input = is_array( $input ) ? $input : array(); $clean = array(); $previous = $this->get_settings(); foreach ( $this->integrations as $key => $integration ) { if ( ! $this->is_available( $key ) ) { // The checkbox is disabled and not submitted: keep what was saved, so a plugin // that is only deactivated for a while comes back protected. $clean[ $key ] = ( ! isset( $previous[ $key ] ) || ! empty( $previous[ $key ] ) ) ? 1 : 0; continue; } $clean[ $key ] = ! empty( $input[ $key ] ) ? 1 : 0; } $clean['ip_block_threshold'] = isset( $input['ip_block_threshold'] ) ? min( 100, max( 0, (int) $input['ip_block_threshold'] ) ) : 5; $clean['ip_block_duration'] = isset( $input['ip_block_duration'] ) ? min( 8760, max( 1, (int) $input['ip_block_duration'] ) ) : 24; $clean['enable_log'] = ! empty( $input['enable_log'] ) ? 1 : 0; $clean['trusted_proxies'] = $this->sanitize_trusted_proxies( isset( $input['trusted_proxies'] ) ? $input['trusted_proxies'] : '' ); $clean['delete_data'] = ! empty( $input['delete_data'] ) ? 1 : 0; $clean['load_everywhere'] = ! empty( $input['load_everywhere'] ) ? 1 : 0; $clean['ip_allowlist'] = $this->sanitize_trusted_proxies( isset( $input['ip_allowlist'] ) ? $input['ip_allowlist'] : '' ); $clean['ip_blocklist'] = $this->sanitize_trusted_proxies( isset( $input['ip_blocklist'] ) ? $input['ip_blocklist'] : '' ); $clean['block_words'] = $this->sanitize_word_rules( isset( $input['block_words'] ) ? $input['block_words'] : '' ); $clean['block_disposable'] = ! empty( $input['block_disposable'] ) ? 1 : 0; $clean['login_throttle'] = ! empty( $input['login_throttle'] ) ? 1 : 0; $clean['weekly_email'] = ! empty( $input['weekly_email'] ) ? 1 : 0; $clean['bot_test_button'] = ! empty( $input['bot_test_button'] ) ? 1 : 0; // Bust cached settings. $this->cached_settings = null; $this->content = null; return $clean; } public function render_settings_page() { $options = wp_parse_args( $this->get_settings(), $this->get_defaults() ); $blocked_count = (int) get_option( 'accessnow_spam_eater_blocked_count', 0 ); $log = $this->is_log_enabled() ? array_reverse( $this->prune_log( get_option( 'accessnow_spam_eater_log', array() ) ) ) : array(); $blocked_ips = $this->prune_blocked_ips( $this->get_blocked_ips(), time() ); $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? $this->normalise_ip( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) ) : ''; $datetime_fmt = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); $behind_proxy = ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) || ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) || ! empty( $_SERVER['HTTP_X_REAL_IP'] ); // Group integrations under translated headings. $group_labels = $this->get_group_labels(); $texts = $this->get_integration_texts(); $groups = array(); foreach ( $this->integrations as $key => $integration ) { $group = $group_labels[ $integration['group'] ]; if ( ! isset( $groups[ $group ] ) ) { $groups[ $group ] = array(); } $integration['label'] = $texts[ $key ][0]; $integration['desc'] = $texts[ $key ][1]; $groups[ $group ][ $key ] = $integration; } ?>

$group_integrations ) : ?>

$integration ) : $is_available = $this->is_available( $key ); ?>

' . esc_html( $remote_addr ) . '' ); ?>

count_disposable_domains() ) ) ); ?>

count_throttled_logins(); if ( $waiting > 0 ) : ?>


$entry ) : ?>
is_log_enabled() ) : ?>

get_reason_label( isset( $entry['reason'] ) ? $entry['reason'] : '' ) ); ?>