A security review of the WordPress plugin Bulk remove posts from category raised an important concern about the AJAX handler:
masterns_bulk_remove_cat_edit_hook()
in:
wp-content/plugins/bulk-remove-posts-from-category/wpbulkremove.php
The reported code verifies a WordPress nonce before processing the request, but does not verify whether the logged-in user is actually authorized to edit the targeted posts.
That distinction matters.
A nonce protects primarily against forged requests such as CSRF. It does not provide authorization.
WordPress’s own security documentation explicitly warns that nonces must never be relied on for authentication, authorization, or access control and recommends protecting sensitive functions with current_user_can().
So, for the affected implementation, the security concern is valid.
However, the exact impact needs to be described carefully.
What the Plugin Actually Does
Bulk remove posts from category extends WordPress’s native bulk editor and lets users remove categories or other taxonomy terms from:
- posts;
- WooCommerce products;
- custom post types;
- supported custom taxonomies.
The plugin currently has more than 10,000 active installations, and WordPress.org lists version 3.4 as the current release.
Its AJAX action is registered as:
add_action(
'wp_ajax_masterns_bulk_remove_cat',
'masterns_bulk_remove_cat_edit_hook'
);
The wp_ajax_ prefix means the request is available to authenticated WordPress users.
That alone does not mean every authenticated user should be allowed to perform the operation.
The handler still needs to verify that the current user has permission to modify the requested posts.
The Older Handler Checked the Nonce but Not Authorization
An older 3.4 source snapshot of the handler effectively follows this structure:
function masterns_bulk_remove_cat_edit_hook() {
if ( ! wp_verify_nonce(
$_POST['security'],
'brfc_security_nonce'
) ) {
wp_send_json_error(
'Invalid security token sent.'
);
wp_die();
}
foreach ( $_POST['post_ids'] as $id ) {
$post_id = (int) $id;
foreach ( $_POST['catout'] as $cat ) {
// Determine taxonomy and term.
wp_remove_object_terms(
$post_id,
$cat_id,
$cat_tax
);
}
}
die();
}
The important missing step is something equivalent to:
current_user_can()
before the taxonomy relationship is changed.
That creates a classic authorization problem:
Authenticated user
↓
Valid nonce
↓
AJAX request accepted
↓
No permission check
↓
Taxonomy terms modified
A nonce answers:
Did this request contain an expected security token?
It does not answer:
Is this particular user allowed to modify this post?
Those are separate security questions.
Could a Subscriber Exploit It?
In the affected implementation, the concern is more realistic than simply saying:
An attacker would need to somehow obtain a nonce.
The older code also enqueued the plugin’s JavaScript throughout the WordPress admin and localized a nonce into that script without first checking an editing capability.
Conceptually:
wp_localize_script(
'wpbulkremove-js',
'wpbulkremove',
array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'security' => wp_create_nonce(
'brfc_security_nonce'
),
)
);
There was no equivalent gate such as:
current_user_can( 'edit_posts' )
around that nonce generation.
A normal Subscriber can access parts of /wp-admin/.
Therefore, under the affected code, a low-privileged authenticated account could potentially receive the same AJAX nonce while visiting an admin page.
That materially strengthens the security finding.
The attack path could look like:
Subscriber logs in
↓
Visits WordPress admin
↓
Plugin JavaScript is loaded
↓
AJAX nonce becomes available
↓
Subscriber sends crafted AJAX request
↓
Handler validates nonce
↓
No capability check
↓
Terms removed from selected posts
This is why a nonce alone cannot replace authorization.
Important Correction: It Does Not Delete Posts
The original report says:
a subscriber user could delete posts as long as they have a valid nonce
That overstates the demonstrated impact.
The relevant code calls:
wp_remove_object_terms()
It does not call functions such as:
wp_delete_post()
or:
wp_trash_post()
So the direct security impact is:
unauthorized removal of taxonomy terms from posts or other supported objects
rather than deletion of the posts themselves.
For normal WordPress posts, this could remove categories.
For WooCommerce products, it could potentially alter product-category relationships.
For custom post types, it could modify whatever supported taxonomy was submitted to the handler.
That can still be serious.
For example:
Product
Category: Featured Products
↓
Unauthorized AJAX request
↓
Category removed
↓
Product disappears from that category archive
Depending on how a site uses taxonomies, unauthorized term removal could affect:
- navigation;
- product organization;
- filtered listings;
- category archives;
- automation;
- membership logic;
- custom application logic;
- SEO landing pages.
But it should not be described as arbitrary post deletion unless another code path demonstrates that capability.
current_user_can( 'edit_posts' ) Is a Good Start, but It Is Not Enough by Itself
The proposed fix is:
current_user_can()
That is correct.
However, because the request accepts arbitrary post IDs, the strongest implementation should not rely only on:
current_user_can( 'edit_posts' )
at the beginning of the request.
Consider an Author.
An Author can generally edit their own posts.
They cannot necessarily edit another author’s posts.
So this:
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
'Insufficient permissions.',
403
);
}
blocks Subscribers, but it does not by itself prove that the user can edit every submitted post ID.
WordPress provides the object-specific capability:
current_user_can(
'edit_post',
$post_id
)
for exactly this purpose.
The Better Authorization Pattern
The handler should have two authorization layers.
First:
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
'Insufficient permissions.',
403
);
}
Then, inside the loop:
foreach ( $post_ids as $post_id ) {
if (
! current_user_can(
'edit_post',
$post_id
)
) {
continue;
}
// Perform authorized change.
}
This protects against both:
Subscriber
→ cannot edit posts at all
and:
Author
→ cannot modify someone else's post
WordPress core uses the same object-specific approach when performing native bulk post edits.
Inside bulk_edit_posts(), WordPress checks:
current_user_can(
'edit_post',
$post_id
)
for each individual post before processing it.
That is a useful model for this plugin.
Custom Taxonomies Deserve a Capability Check Too
Because the plugin supports multiple taxonomies and custom post types, an even more complete solution should consider the taxonomy’s own capability.
WordPress taxonomy objects provide:
$taxonomy_obj->cap->assign_terms
WordPress core checks this capability when processing taxonomy data during bulk edits.
A hardened implementation could therefore also perform:
$taxonomy_obj = get_taxonomy( $taxonomy );
if (
! $taxonomy_obj
|| ! current_user_can(
$taxonomy_obj->cap->assign_terms
)
) {
continue;
}
That protects custom taxonomies whose permissions differ from ordinary post categories.
A Hardened Handler Could Look Like This
A simplified defensive pattern would be:
function masterns_bulk_remove_cat_edit_hook() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
'Insufficient permissions.',
403
);
}
check_ajax_referer(
'brfc_security_nonce',
'security'
);
if (
empty( $_POST['post_ids'] )
|| ! is_array( $_POST['post_ids'] )
) {
wp_send_json_error(
'Missing post data.',
400
);
}
$post_ids = array_map(
'absint',
wp_unslash( $_POST['post_ids'] )
);
foreach ( $post_ids as $post_id ) {
if (
! current_user_can(
'edit_post',
$post_id
)
) {
continue;
}
// Validate taxonomy and term.
$taxonomy_obj = get_taxonomy(
$taxonomy
);
if (
! $taxonomy_obj
|| ! current_user_can(
$taxonomy_obj->cap->assign_terms
)
) {
continue;
}
wp_remove_object_terms(
$post_id,
$term_id,
$taxonomy
);
}
wp_send_json_success();
}
The important concept is:
Nonce
+
Endpoint capability
+
Object-specific capability
+
Input validation
rather than:
Nonce only
The JavaScript Should Also Be Restricted
There is another useful defense-in-depth improvement.
The plugin’s scripts do not need to load for Subscribers or on unrelated WordPress admin pages.
A safer enqueue function can check both the page and capability:
function wpbulkremove_enqueue( $hook ) {
if (
'edit.php' !== $hook
|| ! current_user_can( 'edit_posts' )
) {
return;
}
// Load plugin JS/CSS and nonce.
}
This has two benefits:
Unauthorized users
→ do not receive the plugin interface
and:
Unrelated admin pages
→ do not unnecessarily load plugin assets
It is still essential to protect the AJAX endpoint itself.
Client-side restrictions are never a replacement for server-side authorization.
There Is Evidence the Code Has Already Been Hardened
During review of this report, I found an important complication.
An older public source snapshot identified as version 3.4 contains the nonce-only handler described in the report.
But a newer public source snapshot—also still identifying itself as version 3.4—contains several security improvements.
The newer handler includes:
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
'Insufficient permissions.',
403
);
}
It then uses:
check_ajax_referer(
'brfc_security_nonce',
'security'
);
and, critically, checks every target post with:
if (
! $post_id
|| ! current_user_can(
'edit_post',
$post_id
)
) {
continue;
}
The newer source also limits its admin assets to:
'edit.php'
and users with:
edit_posts
capability.
The public source diff I inspected is dated October 23, 2026 and still carries the plugin header:
Version: 3.4
That means there are apparently at least two different source states labelled 3.4 in circulation.
Why Keeping the Same Version Number Is a Problem
If a plugin security fix is distributed while keeping:
Version: 3.4
instead of releasing:
Version: 3.4.1
sites already running an older 3.4 copy may not receive a normal WordPress update notification.
WordPress plugin updates are driven primarily by version comparison.
So this situation is possible:
Site A
3.4 — older vulnerable file
Site B
3.4 — hardened file
and both WordPress dashboards display:
Version 3.4
That makes security verification much harder.
For an upstream security patch, the preferable release process is:
3.4
affected implementation
↓
security fix
↓
3.4.1
patched implementation
with a changelog entry describing the authorization hardening.
How to Check Your Own Installation
Do not rely only on:
Plugins → Version 3.4
Open:
wp-content/plugins/
bulk-remove-posts-from-category/
wpbulkremove.php
and find:
function masterns_bulk_remove_cat_edit_hook()
Look for both of these checks.
Endpoint-level check
current_user_can( 'edit_posts' )
Per-post check
current_user_can(
'edit_post',
$post_id
)
Also inspect:
wpbulkremove_enqueue()
A hardened copy should avoid exposing the plugin’s nonce/interface to users who cannot edit posts.
If those checks are absent, your installed source matches the older affected implementation.
Do Not Simply Add One Check and Stop There
A quick local patch such as:
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die();
}
is substantially better than the nonce-only implementation.
But because arbitrary IDs are accepted from $_POST, per-object authorization is also important.
The preferred hierarchy is:
Can user access this operation?
↓
edit_posts
↓
Can user edit THIS specific post?
↓
edit_post, $post_id
↓
Can user assign/remove terms
for THIS taxonomy?
↓
assign_terms
This follows WordPress’s capability model instead of assuming all authenticated editors have identical permissions.
Why a Nonce Is Still Necessary
Adding current_user_can() does not make the nonce unnecessary.
The two protections defend against different threats.
Nonce
Protects against forged requests:
Administrator visits malicious site
↓
Malicious page tries to submit
WordPress action
↓
Valid nonce missing
↓
Request rejected
Capability check
Protects against unauthorized users:
Subscriber intentionally calls
admin-ajax.php
↓
User is authenticated
↓
Capability missing
↓
Request rejected
A secure state-changing AJAX action normally needs both.
WordPress’s documentation explicitly states that nonces should not be treated as authorization and recommends current_user_can() for access control.
Is This a Security Vulnerability?
For the older handler, the missing authorization check represents a legitimate broken access-control condition.
A careful description would be:
An authenticated low-privileged user who can obtain the plugin’s AJAX nonce may be able to remove taxonomy terms from posts they are not authorized to edit.
Because the older plugin code appears to expose that nonce throughout the WordPress admin, the practical prerequisite may be relatively easy for a normal authenticated account to satisfy.
It should not be described as:
Subscribers can delete arbitrary posts.
The demonstrated operation changes taxonomy relationships rather than deleting posts.
It also should not be assigned a CVE, CVSS severity, or confirmed exploitation status without an appropriate vulnerability disclosure and independent validation.
What About Pull Requests?
The WordPress.org plugin page identifies the project as open-source and links to its WordPress.org source/SVN development repository.
WordPress.org’s SVN repository itself does not provide a GitHub-style public pull-request workflow.
If the maintainer maintains a separate GitHub repository, a PR could be appropriate.
Otherwise the responsible workflow is generally:
Contact maintainer privately
↓
Provide minimal security report
↓
Provide proposed patch/diff
↓
Allow maintainer to release fix
↓
Publish technical details
after patched users can update
Because this involves access control, privately notifying the maintainer before publishing detailed exploitation instructions is preferable.
The current WordPress.org support report has already disclosed the core issue publicly, so the priority should now be ensuring that a clearly versioned patched release reaches existing installations.
What the Maintainer Should Do
A robust upstream fix should include:
- Restrict
wpbulkremove_enqueue()to the appropriate admin page and users with editing capability. - Use
check_ajax_referer()for the AJAX nonce. - Require an appropriate endpoint capability.
- Check
current_user_can( 'edit_post', $post_id )for every submitted post. - Validate that submitted post IDs and taxonomy data are arrays.
- Sanitize taxonomy names.
- Validate taxonomy existence.
- Sanitize term IDs with
absint(). - Consider the taxonomy’s
assign_termscapability. - Return structured JSON errors rather than bare
die(). - Publish the fix under a new plugin version number.
Interestingly, the newer source snapshot already implements most of these measures.
Recommended Release
If the official downloadable plugin has indeed already been changed while retaining version 3.4, the best next step would be to release something like:
3.4.1
with a changelog entry such as:
Security:
Added capability validation to the
bulk category-removal AJAX handler
and restricted admin asset loading.
That would ensure WordPress installations running an older 3.4 build are actually prompted to update.
Final Assessment
The security report is fundamentally correct:
Nonce verification alone
≠
Authorization
The affected handler should verify user capabilities before modifying taxonomy relationships.
The most important refinement is that the direct demonstrated impact is unauthorized category/taxonomy removal, not deletion of the post itself.
The safest design is:
Authenticated AJAX request
↓
current_user_can( 'edit_posts' )
↓
Nonce validation
↓
Validate request
↓
For every post:
current_user_can( 'edit_post', $post_id )
↓
Validate taxonomy capability
↓
Remove term
A newer source snapshot already contains the two most important capability checks, suggesting this issue may have been addressed in code while the plugin still reports version 3.4.
For site owners, that means the version number alone is not enough: inspect the installed handler or install a clearly versioned security release when the maintainer publishes one.