Skip to content

fix: enforce per-post edit_post capability in bulk audio handlers#572

Merged
galbus merged 1 commit into
mainfrom
claude/intelligent-feistel-c1fabd
Jul 19, 2026
Merged

fix: enforce per-post edit_post capability in bulk audio handlers#572
galbus merged 1 commit into
mainfrom
claude/intelligent-feistel-c1fabd

Conversation

@galbus

@galbus galbus commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

The redirect-based bulk actions in src/posts-list/class-bulk-edit.php mutated and remotely deleted BeyondWords audio without a per-post capability check, allowing a low-privilege user to act on posts they cannot edit.

handle_bulk_generate_action() and handle_bulk_delete_action() are wired to the handle_bulk_actions-edit-{post_type} filter. WordPress core routes custom bulk actions through that filter after only check_admin_referer('bulk-posts') and the coarse current_user_can($ptype->cap->edit_posts) gate at the top of edit.php — it never checks per-post edit_post for custom actions (only for built-ins like trash). Both handlers then acted on every submitted ID.

The AJAX sibling save_bulk_edit() in the same class already filters IDs by current_user_can('edit_post', $id) (with a comment explaining exactly this risk); the redirect handlers omitted the equivalent guard.

Impact

A Contributor or Author (both hold edit_posts, so they can load edit.php and read the bulk-posts nonce from their own list-table form) could craft:

edit.php?action=beyondwords_delete_audio&post[]=<another-author's-post-ID>&_wpnonce=<their-bulk-posts-nonce>

Core intval()s the IDs and passes them straight to the filter. The result was a privilege-boundary violation on content the user cannot edit:

  • Delete: a remote batch-delete to the BeyondWords API + wipe of all ~38 beyondwords_*/speechkit_* meta keys per post (irreversible).
  • Generate: beyondwords_generate_audio meta writes + billable API generation/regeneration.

The list-table UI hides checkboxes for uneditable posts, but the crafted request bypasses that.

Fix

At the top of both handlers, filter the IDs exactly like the AJAX path:

$object_ids = array_values(
    array_filter(
        $object_ids,
        static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
    )
);

…and bail with a zero-count redirect when nothing editable remains. On the delete path this also prevents handing an empty batch to the API (which would otherwise surface a spurious BULK-NO-RESPONSE error).

Tests

tests/phpunit/posts-list/test-bulk-edit.php:

  • Added 4 tests for the per-post filtering on both redirect handlers — a "skips posts the user cannot edit" case (author vs. another author's published post; asserts only the editable post is flagged/deleted and, for delete, that the API request carries only the editable post's content) and a "no-op when no editable posts remain" case (zero count, no API request) for each.
  • Fixed 2 existing integration tests that invoked the handlers as user 0 — now correctly rejected by the new gate — to run as an administrator, plus a wp_set_current_user(0) reset in tearDown.

Verification

  • npm run phpcs (WordPress-VIP-Go): clean, no phpcs:ignore added.
  • PHPUnit --filter BulkEdit: 27 tests, 110 assertions, all pass (4 new tests, 2 fixed integration tests, and all existing AJAX-path coverage). Full Cypress suite not run.

Reviewer notes

  • This closes the redirect-handler gap so it matches the guard the AJAX path already had. Nonce verification is still handled upstream by core's check_admin_referer('bulk-posts'), so no nonce change was needed here.
  • Behaviour for a normal capable user (e.g. an editor/admin selecting posts they can edit) is unchanged.

The redirect-based bulk actions handle_bulk_generate_action() and
handle_bulk_delete_action() acted on the selected post IDs without a
per-post capability check. WordPress core routes custom bulk actions
through the handle_bulk_actions-edit-{post_type} filter after only a
coarse current_user_can(edit_posts) gate, so a Contributor/Author could
craft an edit.php request targeting another author's post to force
(billable) audio generation, or remotely delete audio and wipe all
BeyondWords/speechkit meta, on content they cannot edit.

Filter $object_ids by current_user_can('edit_post', $id) at the top of
both handlers, mirroring the guard already present in the AJAX sibling
save_bulk_edit(), and bail with a zero count when nothing editable
remains (so the delete path never hands an empty batch to the API).

Add PHPUnit coverage for the per-post filtering on both redirect
handlers, and fix two existing integration tests that invoked the
handlers as user 0 (now correctly rejected by the gate) to run as an
administrator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@galbus
galbus marked this pull request as ready for review July 19, 2026 07:29
@galbus
galbus requested a review from gouravkhunger July 19, 2026 07:30
@galbus
galbus merged commit fc85bd2 into main Jul 19, 2026
12 checks passed
@galbus
galbus deleted the claude/intelligent-feistel-c1fabd branch July 19, 2026 07:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants