The Exploit
An authenticated WordPress user with subscriber-level access or higher can invoke privileged administrative functions by sending forged AJAX requests to multiple handlers in the Edwiser Bridge plugin.
curl -X POST http://target.local/wp-admin/admin-ajax.php \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Cookie: wordpress_logged_in=<subscriber-session>" \
-d "action=wdm_eb_get_email_template&tmpl_name=welcome&admin_nonce=<valid-nonce>"
The attacker observes a 200 response containing serialized email template data intended for administrators only. No permission error is returned. The attacker can then chain this to modify email templates, send test emails to arbitrary addresses, or reset template content — all without holding the manage_options capability.
Why this still matters at admin: A compromised admin account (session theft, credential stuffing, supply chain compromise) or a multi-tenant WordPress installation with segregated admin roles can escalate privileges through these unguarded AJAX handlers. A shop manager role granted only edit_posts can invoke functions expecting manage_options, bypassing role-based access control.
What the Patch Did
Before:
public function get_template_data_ajax_call_back() {
$data = array();
// Process only if nonce is verified.
if ( isset( $_POST['tmpl_name'] ) && isset( $_POST['admin_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['admin_nonce'] ) ), 'eb_admin_nonce' ) ) {
After:
public function get_template_data_ajax_call_back() {
$data = array();
// SECURITY FIX: Check user capability before processing.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => esc_html__( 'You do not have permission to perform this action.', 'edwiser-bridge' ) ) );
}
// Process only if nonce is verified.
if ( isset( $_POST['tmpl_name'] ) && isset( $_POST['admin_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['admin_nonce'] ) ), 'eb_admin_nonce' ) ) {
The patch added a single authorization gate using the WordPress current_user_can( 'manage_options' ) capability check. This primitive queries the current user's assigned roles and capabilities before executing sensitive operations. If the check fails, the function terminates early and returns a JSON error response. The patch applied this pattern across four AJAX handlers: get_template_data_ajax_call_back(), send_test_email(), reset_email_template_content(), and eb_enable_course_enrollment_method().
Root Cause
CWE-862: Missing Authorization (cwe.mitre.org/data/definitions/862.html)
The vulnerable code trusts nonce verification alone to gate access to administrative functions. In WordPress, a nonce is a CSRF token — it proves the request originated from a legitimate page within the same site, but it does not encode role or capability information. An authenticated subscriber receives a valid eb_admin_nonce on the settings page (if they visit it) and can reuse it in requests to admin-ajax.php without any subsequent check of their user role.
The wp_ajax_nopriv_wdm_eb_get_email_template and wp_ajax_nopriv_wdm_eb_send_test_email hooks in includes/class-eb.php register the same handlers for both authenticated and unauthenticated users. This compounds the issue: even without a valid session, an attacker can invoke these handlers if they bypass nonce validation (or obtain a valid nonce through an open settings page endpoint). The dataflow is: $_POST['admin_nonce'] → wp_verify_nonce() → handler executes. Capability check is absent.
Why It Works
The single line if ( ! current_user_can( 'manage_options' ) ) is load-bearing. Removing it restores the vulnerability. Without this check, any authenticated user whose nonce passes verification will execute the function.
The engineer correctly placed the capability check before the nonce verification loop. This is defense-in-depth: if the nonce check itself is bypassed or misconfigured (e.g., a typo in the action name), the capability gate still blocks execution. The order matters: capability checks are "always-on" authorization, while nonces prevent CSRF but require a valid session or prior page visit to obtain. Checking capability first is faster and prevents redundant downstream logic from running in the unauthenticated case.
The companion fix in includes/class-eb.php removes the wp_ajax_nopriv_* hook registrations for these handlers entirely. This prevents unauthenticated users from reaching the handler at all, stopping the attack at the routing layer. WordPress will not dispatch the AJAX request to an unauthenticated user if no wp_ajax_nopriv_ hook is registered. Together, these two changes create a two-layer gate: dispatcher-level (hook registration) and function-level (capability check).
Hardening Checklist
-
Audit all AJAX handlers registered with
wp_ajax_nopriv_*hooks. These are intentionally public; verify that each one's corresponding function contains an earlycurrent_user_can()check with the narrowest required capability, and document why unauthenticated access is necessary. Remove thewp_ajax_nopriv_hook if authenticated access is sufficient. -
Wrap every AJAX function with a capability gate before nonce verification. Use
if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( ... ); }as a template. Place this check on the first line of the handler, not inside conditional branches that parse user input. -
Use
check_ajax_referer()instead of manualwp_verify_nonce()calls. This function halts execution immediately on failure, preventing partial state changes. For example:check_ajax_referer( 'eb_admin_nonce', 'admin_nonce' );instead ofif ( wp_verify_nonce( ... ) ) { ... }. -
Grep the codebase for
wp_ajax_nopriv_hooks and verify each against a hardening matrix. Columns: hook name, handler function, required capability, business justification. Any public AJAX endpoint should be filed as a ticket for review. -
Test role downgrade scenarios in integration tests. Create a subscriber user, obtain a nonce from a public page (or inject it via a fixture), then invoke sensitive AJAX handlers. Assert that
wp_send_json_error()is returned, not the sensitive data.