The Exploit
An authenticated WordPress user with contributor-level access can permanently delete any reusable social message, including those authored by administrators, by sending a DELETE request to the REST API endpoint without ownership or capability verification.
DELETE /wp-json/nelia-content/v1/reusable-messages/42 HTTP/1.1
Host: target.local
Authorization: Bearer <contributor_token>
Cookie: wordpress_logged_in=<contributor_session>
Content-Type: application/json
The server responds with HTTP 200 and the message is permanently deleted from the database. An attacker with contributor credentials observes the message disappears from the editorial calendar UI immediately, and subsequent requests to fetch that message ID return 404. No audit log or capability error is raised; the deletion appears legitimate.
What the Patch Did
Before:
'permission_callback' => 'nelio_content_can_current_user_use_plugin',
After:
'permission_callback' => array( $this, 'can_current_user_delete_reusable_message_from_request' ),
And within the endpoint handler:
Before:
public function remove_reusable_message( $request ) {
$message = $request->get_param( 'message' );
assert( $message instanceof Nelio_Content_Reusable_Message );
$message->delete();
return new WP_REST_Response( array( 'deleted' => true ), 200 );
}
After:
public function remove_reusable_message( $request ) {
$message = $request->get_param( 'message' );
assert( $message instanceof Nelio_Content_Reusable_Message );
if ( ! empty( $message->ID ) && ! $this->can_current_user_delete_reusable_message( $message->ID ) ) {
return new WP_Error(
'rest_forbidden',
_x( 'You are not allowed to delete this message.', 'text', 'nelio-content' ),
array( 'status' => 403 )
);
}
$message->delete();
return new WP_REST_Response( array( 'deleted' => true ), 200 );
}
The patch replaced a generic plugin-access check (nelio_content_can_current_user_use_plugin) with a granular, message-aware authorization function (can_current_user_delete_reusable_message_from_request) that verifies both the route-level permission callback and a second guard inside the handler itself. The handler now invokes can_current_user_delete_reusable_message() to validate that the requesting user either owns the message, is a plugin manager, or holds the delete_post capability scoped to that post ID.
Root Cause
CWE-639: Authorization Bypass Through User-Controlled Key — The DELETE /wp-json/nelia-content/v1/reusable-messages/{id} endpoint accepted a message request parameter containing a Nelio_Content_Reusable_Message object. The route's permission callback only checked whether the current user had general plugin access via nelio_content_can_current_user_use_plugin(), which returns true for any authenticated user with contributor role or above. This callback did not examine the specific message being deleted — only the user's global permissions. The handler then unconditionally called $message->delete() without re-validating that the user owned the message or held explicit delete authority over that post. An attacker could craft a DELETE request targeting any message ID in the database; as long as they were authenticated with a contributor account, the permission callback would pass and the deletion would succeed.
Why It Works
The load-bearing line is the authorization check inside remove_reusable_message():
if ( ! empty( $message->ID ) && ! $this->can_current_user_delete_reusable_message( $message->ID ) ) {
Without this, the old code reaches $message->delete() unchecked. The permission callback alone is insufficient because REST permission callbacks execute before the handler sees the request body or route parameters — they cannot evaluate the specific post ID being deleted. The callback upgrade to can_current_user_delete_reusable_message_from_request() acts as a secondary gate at the route level, but the real gatekeeper is the check inside the handler, which has access to the hydrated Nelio_Content_Reusable_Message object and can call can_current_user_delete_reusable_message( $message->ID ) with the actual post ID. If the engineer had removed only the route callback change and left the handler unguarded, the vulnerability would persist; if they had added only the route callback without the handler check, a bypass might still be possible if the callback failed to fully validate the message ID parameter. Defence-in-depth: the callback validates early; the handler validates late with full context.
Hardening Checklist
-
Separate route callbacks from handler logic. Do not assume a route-level permission callback (
permission_callbackin REST registration) can evaluate request-specific resources like post IDs. Always re-validate inside the handler usingget_post( $id )or$post->IDand callcurrent_user_can( 'delete_post', $id )with the post as a second argument to scope the capability check. -
Use
current_user_can()with resource IDs. Never callcurrent_user_can( 'manage_options' )orcurrent_user_can( 'delete_post' )without passing the post ID as a second parameter. WordPress capability checks that omit the resource ID may return true for users who should not have access to that specific post. -
Audit all REST endpoints that mutate state. Search for
register_rest_route()calls with'methods' => 'POST|PUT|DELETE'and verify that each endpoint callback contains an explicit capability or ownership check that references the resource ID from the request, not just the global user role. -
Log authorization failures. Wrap capability denials in
error_log()or a dedicated audit table so privilege-escalation attempts are visible to site administrators monitoring the error log or audit trail. -
Test with contributor-level roles. Reproduce all CRUD operations as a contributor, subscriber, and unauthenticated user; verify that delete and edit operations return 403 when the user does not own the resource or hold an admin role.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-94505