The Exploit
Unauthenticated attacker.
curl -s -G 'https://TARGET/wp-json/cubewp-posts/v1/query-new' \
--data-urlencode 'paged=1' \
-H 'Accept: application/json' | jq '.posts[] | {ID, post_title, post_status, post_password}'
Response excerpt:
{
"ID": 42,
"post_title": "Private launch notes",
"post_status": "private",
"post_password": ""
}
{
"ID": 73,
"post_title": "Draft blog post",
"post_status": "draft",
"post_password": ""
}
{
"ID": 91,
"post_title": "Secret event",
"post_status": "publish",
"post_password": "hunter2"
}
The attacker gets a JSON list of posts from /cubewp-posts/v1/query-new without logging in; private, draft, and password-protected posts appear in the response. The same issue also affects /cubewp-posts/v1/query.
What the Patch Did
Before:
register_rest_route(
'cubewp-posts/v1',
'/query-new',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_posts' ),
'permission_callback' => function () {
return true;
},
)
);
$data = array(
'total_posts' => $posts->found_posts,
'paged' => $posts->query['paged'],
'max_num_pages' => $posts->max_num_pages,
'posts' => $posts->posts,
);
After:
register_rest_route(
'cubewp-posts/v1',
'/query-new',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_posts' ),
'permission_callback' => array( $this, 'get_posts_permission_check' ),
)
);
$filtered_posts = array();
foreach ( $posts->posts as $post ) {
if ( ! $this->can_user_read_post( $post ) ) {
continue;
}
$safe_post = $this->sanitize_post_for_response( $post );
if ( $safe_post ) {
$filtered_posts[] = $safe_post;
}
}
$data = array(
'total_posts' => count( $filtered_posts ),
'paged' => isset( $posts->query['paged'] ) ? $posts->query['paged'] : 1,
'max_num_pages' => $posts->max_num_pages,
'posts' => $filtered_posts,
);
The patch replaces the hardcoded permission_callback with a dedicated permission check and removes raw $posts->posts from the response. It adds per-post visibility filtering via can_user_read_post() and sanitizes objects before returning them.
Root Cause
This was a broken access control bug (CWE-639) that turned an unauthenticated REST request for /cubewp-posts/v1/query-new or /cubewp-posts/v1/query into a direct data leak. The endpoint accepted attacker-controlled requests and returned the raw $posts->posts array, so private/draft/password-protected posts crossed the security boundary unchecked. The request did not need any special parameter beyond the default query payload (paged), and the plugin trusted WordPress query output without applying per-post read permissions.
Why It Works
The load-bearing fix is the per-post visibility check: if ( ! $this->can_user_read_post( $post ) ) { continue; }. That line enforces WordPress read access on each post before it reaches the API response. Without it, even if the endpoint had a more sensible permission callback, the response would still contain sensitive post objects from the original query. The permission callback change is a second layer: it moves endpoint access control out of a constant true return and into a method where future rules can be enforced consistently. The remainder of the patch is defence-in-depth, ensuring only sanitized, permitted posts are counted and returned.
Hardening Checklist
- Register REST routes with a real
permission_callback; do not usereturn truefor endpoints that expose content. - Apply
current_user_can('read_post', $post)or equivalent per-item visibility checks before returning post objects from REST responses. - Never return raw
$posts->postsor fullget_post_meta()output; sanitize withrest_prepare_post()or explicit safe-field filtering. - Handle protected content explicitly using
post_password_required()andget_post_status()rather than assumingWP_Querywill only return public posts. - Use
rest_ensure_response()and explicit response shaping to avoid leaking WordPress internals.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-12129