Botsany blocks all anonymous wp_ajax_nopriv_* requests with false positives

Bug Report: botsany Anti-Bot Engine Blocks All Anonymous wp_ajax_nopriv_* Requests

WP Cerber Version: 9.7
File: cerber-load.php
Severity: Critical - causes mass false positives AND repeated “Error establishing a database connection” on any site using standard WordPress AJAX
Affected setting: Anti-Spam → “Protect all forms on the website with bot detection engine” (botsany)


Summary

When botsany is enabled and botssafe is disabled (both defaults), WP Cerber blocks all anonymous AJAX POST requests (wp_ajax_nopriv_*) with HTTP 403 and logs them as CRB_EV_SFD (“Spam form submission denied”). This affects any theme or plugin that uses WordPress’s standard public AJAX API.

The root cause is that cerber_is_bot() and cerber_is_antibot_exception() only exempt logged-in users and the heartbeat action from anti-bot token verification. All other anonymous AJAX calls are required to carry hidden form fields that are structurally impossible to include in programmatic JavaScript AJAX requests.


Reproduction

  1. Install any theme or plugin that uses wp_ajax_nopriv_* hooks (e.g., themes with AJAX search, AJAX pagination, AJAX view counters, or any WooCommerce AJAX add-to-cart on product listing pages).
  2. Enable Anti-Spam → Protect other forms (botsany = 1).
  3. Leave Safe AJAX mode disabled (botssafe = 0, which is the default).
  4. As a non-logged-in visitor, trigger any frontend AJAX action.
  5. Observe: the request returns HTTP 403, and cerber_log records activity 17 (CRB_EV_SFD).

Scale of impact: On a high-traffic site, this generates thousands of false-positive rows per hour in both cerber_log and cerber_traffic tables. The write pressure accumulates fast enough to exhaust the database connection pool, causing repeated temporary “Error establishing a database connection” outages visible to real visitors - a direct site availability problem caused entirely by false positives from this bug.


Root Cause Analysis

Three functions in cerber-load.php contain the same structural flaw: they only exempt logged-in users and heartbeat from anti-bot checks on AJAX requests, leaving all wp_ajax_nopriv_* actions unprotected.

1. cerber_is_antibot_exception() - line 2891

// cerber-load.php, line 2891
function cerber_is_antibot_exception(){
    // ...
    if ( is_admin() ) {                          // TRUE for admin-ajax.php
        if ( cerber_is_wp_ajax() ) {             // TRUE (DOING_AJAX is defined)
            if ( is_user_logged_in() ) {         // FALSE for anonymous visitors
                return true;                     // [*] only logged-in users exempted
            }
            if ( class_exists( 'WooCommerce' ) ) {
                // [*] only a specific WooCommerce background process exempted
            }
            // [*] anonymous wp_ajax_nopriv_* falls through here -- NOT exempted
        }
        else {
            return true;
        }
    }
    // ... returns false -> not exempt
}

Problem: Anonymous AJAX requests reach the end of the is_admin() block without hitting any return true. The function proceeds to check comments, XML-RPC, REST API, etc. - none of which match an AJAX request. The function returns false.

2. cerber_is_bot() - line 3459

// cerber-load.php, line 3479
if ( is_admin() ) {
    if ( cerber_is_wp_ajax() ) {
        if ( is_user_logged_in() ) {
            $ret = false;                        // [*] logged-in: not a bot
        }
        elseif ( ! empty( $_POST['action'] ) ) {
            if ( $_POST['action'] == 'heartbeat' ) {
                $ret = false;                    // [*] heartbeat: not a bot
            }
            // [*] ALL other nopriv actions: $ret stays null -> continues to token check
        }
    }
    else {
        $ret = false;
    }
}

Problem: For any anonymous AJAX action other than heartbeat, $ret remains null at line 3497, and execution continues to the anti-bot token verification at line 3553.

3. cerber_antibot_mode() - line 2985

// cerber-load.php, line 2985
function cerber_antibot_mode() {
    if ( current_user_can( 'manage_options' ) ) {
        return 2;                                // Admins: cookies only
    }
    if ( cerber_is_wp_ajax() ) {
        if ( crb_get_settings( 'botssafe' ) ) {
            return 2;                            // Safe mode ON: cookies only
        }
        if ( ! empty( $_POST['action'] ) ) {
            if ( $_POST['action'] == 'heartbeat' ) {
                return 2;                        // Heartbeat: cookies only
            }
        }
    }
    // ...
    return 1;                                    // [*] Default: Cookies + Hidden Fields
}

Problem: When botssafe = 0 (default), anonymous AJAX returns mode 1. Mode 1 requires hidden form fields in $_POST:

// cerber-load.php, line 3561
if ( $mode == 1 ) {
    foreach ( $antibot[0] as $fields ) {
        if ( empty( $_POST[ $fields[0] ] ) || $_POST[ $fields[0] ] != $fields[1] ) {
            $ret = true;  // [*] FLAGGED AS BOT
            break;
        }
    }
}

AJAX requests are programmatic XMLHttpRequest/fetch POST calls made by JavaScript. They send structured data payloads (action=xxx&param=yyy), not HTML form submissions. They never contain Cerber’s dynamically-generated hidden form fields because:

  • Cerber injects hidden fields into rendered HTML <form> elements via cerber_antibot_code()
  • AJAX calls construct their POST body in JavaScript, independent of any HTML form
  • There is no mechanism for third-party JavaScript to discover and include Cerber’s dynamically-named fields

This means mode 1 will always fail for any legitimate AJAX request, regardless of the plugin or theme making the call.


Evidence from Production

Database export from a live site showing the impact:

Table Total rows Rows caused by this bug Percentage
cerber_log ~51,479 ~49,763 (activity 17, botsany) 96.7%
cerber_traffic ~21,998 ~21,998 (HTTP 403 on AJAX POST) 100%

Why tables fill so fast

Each page visit by an anonymous user triggers multiple parallel AJAX POST requests - not one. A typical page with live search, a view counter, lazy-loaded content, and a rating widget can generate 4-8 POST requests per page load. Every single one of those requests:

  1. Is intercepted by cerber_post_control().
  2. Fails the cerber_is_bot() token check (mode 1, missing hidden fields).
  3. Writes one row to cerber_log (activity 17) and one row to cerber_traffic (HTTP 403).

At modest traffic of 200 anonymous visitors per minute, this produces:

200 visitors/min x 6 AJAX calls/visitor x 2 rows/call = 2,400 INSERT operations per minute
                                                       = 144,000 rows per hour
                                                       = 3,456,000 rows per day

MySQL’s InnoDB engine serialises writes to these tables. At this INSERT rate, the database connection pool saturates and WordPress starts returning “Error establishing a database connection” to real visitors - not because the server is down, but because legitimate page loads cannot acquire a connection while Cerber’s false-positive writes are monopolising them.

This bug turns WP Cerber itself into a denial-of-service vector against the site it is supposed to protect.

All blocked requests came from legitimate visitors with real browsers (Chrome, Safari, Samsung Browser, iOS Safari) performing standard frontend AJAX operations. User agents and referer headers confirm these are real users browsing - not bots.

The pattern is not specific to any single theme or plugin. The following examples show common, legitimate wp_ajax_nopriv_* actions registered by widely-used WordPress components - all of which would be blocked by this bug:

Plugin / Component AJAX action Purpose
Any custom theme theme_search, load_more_posts Live search, infinite scroll
WooCommerce woocommerce_get_refreshed_fragments Mini-cart update
Contact Form 7 wpcf7_submit Form submission
Elementor elementor_ajax Widget interactions
BuddyPress bp_ajax_query_string Activity feed loading
The Events Calendar tribe_calendar_json_grid Event grid loading
bbPress bp-logout Session handling
Any membership plugin check_membership, verify_access Content gating

Any plugin or theme that registers add_action( 'wp_ajax_nopriv_my_action', ... ) - which is the officially documented WordPress pattern for anonymous AJAX - will produce false positives when botsany is active and botssafe is disabled.


Suggested Fix

Option A: Automatically use mode 2 (cookies only) for all AJAX requests

In cerber_antibot_mode(), AJAX requests should always use mode 2 regardless of botssafe. The cookie check alone is sufficient proof that JavaScript executed on the page (bots that don’t execute JS won’t have the cookies):

// cerber-load.php, cerber_antibot_mode()
function cerber_antibot_mode() {
    if ( current_user_can( 'manage_options' ) ) {
        return 2;
    }

    if ( cerber_is_wp_ajax() ) {
        return 2;  // AJAX always uses cookies-only mode
    }

    // ... rest of function unchanged
}

Rationale: If DOING_AJAX is true, the request came through admin-ajax.php, which means WordPress has already validated the request path. Requiring hidden form fields for AJAX is architecturally unsound because AJAX payloads are constructed in JavaScript, not submitted from HTML forms. The cookie check (mode 2) is the correct anti-bot mechanism for AJAX: it verifies that JavaScript executed in the visitor’s browser and set the anti-bot cookies, which is exactly what distinguishes real browsers from simple HTTP bots.

Option B: Exempt wp_ajax_nopriv_* actions that have registered handlers

In cerber_is_bot(), after the heartbeat check, verify if the action has a registered wp_ajax_nopriv_* handler. If it does, it’s a legitimate WordPress AJAX action - not a spam form submission:

// cerber-load.php, cerber_is_bot(), inside the is_admin() + cerber_is_wp_ajax() block
elseif ( ! empty( $_POST['action'] ) ) {
    if ( $_POST['action'] == 'heartbeat' ) {
        $ret = false;
    }
    // Check if a nopriv handler is registered for this action
    elseif ( has_action( 'wp_ajax_nopriv_' . $_POST['action'] ) ) {
        $ret = false;
    }
}

Rationale: If a wp_ajax_nopriv_* handler is registered, the action was intentionally created by a plugin or theme for anonymous use. Blocking it as spam contradicts the site administrator’s intent.

Recommended approach

Option A is the safest and most correct fix. It addresses the root cause: hidden form fields are the wrong verification mechanism for AJAX requests. Cookie verification is both sufficient and appropriate - if a visitor’s browser executed Cerber’s JavaScript and set the anti-bot cookies, that is definitive proof the visitor is not a simple HTTP bot. The hidden-fields check adds no security value for AJAX and causes 100% false positives.


Workaround (current)

Users can mitigate this today by enabling:

Anti-Spam → Adjust anti-spam engine → Enable safe AJAX mode (botssafe = 1)

This switches AJAX to mode 2 (cookies only), which resolves the false positives. However, this setting is disabled by default and is described as “may increase spam risk slightly,” which discourages users from enabling it. In practice, mode 2 for AJAX does not increase spam risk because the cookie check already validates JavaScript execution.


Files and Lines Reference

Location Description
cerber-load.php:2839-2884 cerber_post_control() - calls cerber_is_bot('botsany') and denies with 403
cerber-load.php:2864-2867 The specific branch that flags the request and logs CRB_EV_SFD
cerber-load.php:2891-2977 cerber_is_antibot_exception() - missing exemption for anonymous AJAX
cerber-load.php:2898-2912 The is_admin() + cerber_is_wp_ajax() block that only exempts logged-in users
cerber-load.php:2985-3014 cerber_antibot_mode() - returns mode 1 for anonymous AJAX when botssafe=0
cerber-load.php:3459-3586 cerber_is_bot() - main bot detection function
cerber-load.php:3479-3495 The AJAX exemption block that only covers logged-in and heartbeat
cerber-load.php:3561-3567 Mode 1 hidden-field check that always fails for AJAX requests
cerber-common.php:97 const CRB_EV_SFD = 17 - the false-positive activity code
cerber-common.php:1566-1577 cerber_is_wp_ajax() - checks DOING_AJAX constant
cerber-settings.php:389-390 Default values: botsany = 0, botssafe = 0