Skip to content

Security: enforce object-level authorization across the ability services (1.4.0) - #25

Merged
trueqap merged 5 commits into
mainfrom
fix/object-level-authorization
Aug 7, 2026
Merged

Security: enforce object-level authorization across the ability services (1.4.0)#25
trueqap merged 5 commits into
mainfrom
fix/object-level-authorization

Conversation

@trueqap

@trueqap trueqap commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes all 10 findings from the full-plugin security audit (9 HIGH, 1 MEDIUM).

Root cause

Authorization was expressed entirely as a registration-time primitive capability check, and src/Services/ contained zero current_user_can() calls. A primitive capability means "may edit posts in general" — never "may edit THIS post". So any ability whose permission_callback sat one notch too low granted unrestricted access to every object of that type, and WordPress's and WooCommerce's own protections — which live in the meta-capability layer (map_meta_cap, wc_modify_map_meta_cap, get_editable_roles()) — never ran at all.

Verified against WP and WooCommerce source: wp_update_post(), wp_delete_post(), wp_delete_attachment(), update_post_meta(), wp_set_password() and WP_User::set_role() perform no capability check of their own. They are data-layer functions; authorization is the caller's job, and this plugin was not doing it.

What an attacker could do (before this PR)

Role Could do
Contributor Permanently delete any post site-wide (bulk-posts); rewrite/publish/reassign any post, page or product; read every author's drafts and private posts with full private meta; on WooCommerce: list every customer's name/email/phone, read protected order meta (_order_key, _transaction_id, _customer_ip_address), delete orders, create refunds, read internal staff notes
Author Permanently delete any attachment, including the files on disk (force defaulted to true)
shop_manager Reset the administrator's password silently and get the plaintext back; promote itself to administrator via role or by writing {prefix}capabilities user meta

shop_manager matters because WooCommerce grants edit_users to it unconditionally via a user_has_cap filter (wc-user-functions.php:684); its intended restriction (wc_modify_map_meta_cap) only runs for the meta caps this plugin never called.

The fix

Object-level checks now run in the service layer, where the target ID is finally known. Three new focused helpers hold the policy so it cannot drift:

  • Helpers/Capability (112 lines) — object-level meta capability checks
  • Helpers/ProtectedMeta (114 lines) — protected-key and privilege-key policy
  • Services/Meta/MetaGuard (144 lines) — single entry point for the 12 meta methods

Per finding: per-object checks on posts/meta/media/users; the 14 WooCommerce order abilities moved to the existing can_manage_orders; promote_user + get_editable_roles() on role changes (validated before any write, so a rejected role can't half-update an account); role/level/session-token meta keys refused for everyone; protected-key filter applied to single-key reads; post_status no longer 'any' plus perm => 'editable'.

Tests

4 new suites, 52 cases, covering every finding — RED before, GREEN after. The current_user_can and wp_register_ability stubs became controllable so both the allowed and denied branch are exercised (previously current_user_can always returned false, so neither was); added WP_Post, WP_User, WP_Query and several function stubs.

Gate: composer phpcs clean · composer analyse (PHPStan L5) no errors · composer test 423/423.

⚠️ Behaviour change — please read before merging

Lower-privileged roles lose access they previously (incorrectly) had. Any integration running as Contributor, Author or WooCommerce shop_manager may now get 403 forbidden where it previously succeeded — that is the fix working, but it is a breaking change for such setups. Administrators are unaffected. Hence the minor bump to 1.4.0.

Two smaller behaviour changes worth calling out:

  • delete-media now defaults to the trash instead of permanent deletion (pass force: true for the old behaviour).
  • list-posts no longer returns every status by default.

Also included (separate commit)

delete-theme passed the caller's slug straight to core with no validate_file() and no allowlist, so ../plugins recursively deleted wp-content/plugins. This is administrator-gated and therefore not a privilege boundary crossing — it was excluded from the audit findings — but the ability is AI-agent-facing and annotated idempotent, so a hallucinated slug could destroy an unrelated directory. Now allowlisted against wp_get_themes(), mirroring what delete_plugin() already does.

Known follow-up (deliberately not in this PR)

UserManager.php (405) and MediaManager.php (404) crossed the project's 400-line hard limit, and MetaManager.php (529) / PostManager.php (821) were already above it and grew. The new logic went into new classes — the legacy files only received delegating guard calls — but the call sites still add lines. Splitting them is a separate refactor task requiring characterization tests first, and the project rules forbid mixing a security fix with a refactor.

Audit areas confirmed clean, for the record: no SQL injection (all 5 $wpdb files verified), no XSS (the remote plugin registry is correctly escaped), no CSRF gap, no arbitrary option write, no object injection, no code-execution sink, and the MCP transport/discovery surface is correctly manage_options-gated.

trueqap added 5 commits August 7, 2026 09:48
Security audit of the whole plugin found 9 HIGH and 1 MEDIUM finding, all
caused by one architectural defect: authorization was expressed entirely as a
registration-time PRIMITIVE capability check, and src/Services/ contained zero
current_user_can() calls. A primitive capability says "may edit posts in
general", never "may edit THIS post", so any ability whose permission_callback
sat one notch too low granted unrestricted access to every object of that type.
It also meant WordPress's and WooCommerce's own protections — which live in the
meta-capability layer (map_meta_cap, wc_modify_map_meta_cap, get_editable_roles)
— never ran at all.

Verified against WP and WooCommerce source: wp_update_post(), wp_delete_post(),
wp_delete_attachment(), update_post_meta(), wp_set_password() and
WP_User::set_role() perform no capability check of their own. Authorization is
the caller's job, and this plugin was not doing it.

New, focused helpers carry the policy so it cannot drift:
  Helpers/Capability      object-level (meta) capability checks
  Helpers/ProtectedMeta   protected-key and privilege-key policy
  Services/Meta/MetaGuard single entry point for the 12 meta methods

Findings fixed:

1  bulk-posts / update-post / delete-post had no per-object check, so a
   Contributor could rewrite, publish and reassign any post of any type, and
   permanently delete arbitrary posts site-wide (wp_delete_post($id, true)).
   Every bulk item is now authorized individually.
2  The WooCommerce order abilities were gated on can_edit_posts (Contributor).
   All 14 order-scoped registrations now use can_manage_orders, which already
   existed and was already used by the sibling order abilities. The product
   meta abilities move to a new can_manage_products for the same reason.
3  reset-password reset ANY account, including the administrator, silently
   (send_notification:false) and returned the new plaintext password.
4  update-user validated the role against every registered role instead of
   get_editable_roles(), so a delegated user manager could grant itself
   'administrator'. Role changes now require promote_user on the target, and
   the role is validated before anything is written so a rejected role cannot
   leave the account half-updated.
5  wc-get-order-meta leaked protected order meta two ways: include_protected
   was caller-supplied, and the single-key branch skipped the filter entirely
   (exposing _order_key, _transaction_id, _customer_ip_address).
6  list-posts defaulted post_status to 'any', which makes WP_Query build only
   negative status clauses and skip its capability mapping. Expanded to an
   explicit list plus perm=editable; get-post now requires read_post.
7  The post and comment meta abilities had no per-object check, and the
   single-key read branch had no protected-key filter at all.
8  delete-media/update-media acted on ANY attachment for anyone holding
   upload_files, with force defaulting to true — permanent deletion including
   the files on disk. force now defaults to false (trash).
9  set-user-meta could write {prefix}capabilities, which IS the role
   assignment, bypassing both role validation and promote_user. Role, level
   and session-token keys are now refused for every caller at every
   capability, on the meta ability and on update-user's inline meta map alike.
10 wc-list-order-notes exposed internal staff/gateway notes to a Contributor.

Tests: 4 new suites (52 cases) covering every finding, RED before these
changes and GREEN after. The current_user_can and wp_register_ability stubs
became controllable so both the allowed and denied branch are exercised;
WP_Post, WP_User and WP_Query stubs were added.

composer phpcs, composer analyse (PHPStan L5) and composer test all green
(423 tests).
delete_theme() passed the caller's slug straight to core with no basename(),
no validate_file() and no allowlist. WP_Theme::exists() looks like a guard but
is not one: it only fails on theme_not_found, so '../plugins' resolves to an
existing directory and passes, and core's delete_theme() then recursively
deletes it — wp-content/plugins, uploads, or anything else the PHP user can
unlink — with no containment check of its own.

This is administrator-gated (install_themes), and an administrator already has
code execution, so it is not a privilege boundary crossing and was not counted
among the audit findings. It is worth fixing anyway because the ability is
AI-agent-facing and annotated idempotent: a hallucinated or malformed slug
turns a routine cleanup into an unrecoverable deletion with no confirmation.

Mirrors the allowlist delete_plugin() already applies via get_plugins().
…valid

Both found by testing against the live WooCommerce store rather than by the
unit suite.

1. get-post leaked every protected meta key of any PUBLISHED post to anyone
   holding edit_posts. The capability check added earlier is correct and does
   its job — read_post is legitimately satisfied for a published post — but
   format_post() then returned the whole meta map, and collect_post_meta()
   stripped only _edit_lock, _edit_last and _thumbnail_id. Protected keys are
   where plugins keep API keys, licence keys, form payloads and, on non-HPOS
   stores, order PII. They are now hidden from non-administrators.

   Confirmed live: a Contributor reading a published post received
   _lwsec_secret before this change and an empty meta map after, while an
   administrator still receives the value.

2. bulk-posts returned HTTP 500 (ability_invalid_output) whenever any item
   failed: the output schema declares failed_ids as an array of integers, but
   the loop pushed [ 'id' => ..., 'reason' => ... ]. The defect predates this
   branch — it was reachable only when a post did not exist — but the new
   per-item authorization makes a failed item the normal path for a
   lower-privileged caller, so it went from rare to routine. failed_ids now
   carries integers as declared. CommentManager::bulk_comments had the same
   latent bug and is fixed with it.

Test stubs gained the functions and WP_Post properties this exercises, and
get_post_meta() now handles the "all meta" (empty key) branch the way
WordPress does, which is what let the leak pass unit testing in the first
place.
@trueqap

trueqap commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

✅ Verified end-to-end on the live WooCommerce store

Tested on croco2 (WP 7.0.3, WooCommerce 11.0.0, HPOS on) with real Contributor, Author and shop_manager accounts + application passwords, over the actual REST surface.

The premise confirmed live — WooCommerce 11.0.0 still grants shop_manager the primitive capability the old code checked, while denying the meta capability the new code checks:

shop_manager  edit_users (primitive, old check) : YES
shop_manager  edit_user(1) (meta cap, new check): no
shop_manager  promote_user(1)                   : no
contributor   edit_posts : YES    edit_shop_orders : no

Attacks — all closed

Attack Result
contributor → wc-list-orders (customer PII) 403
contributor → wc-get-order / wc-get-order-meta (_billing_email) 403
contributor → wc-list-order-notes (internal notes) 403
contributor → get-post-meta (private key) 403
contributor → get-post on admin draft 403
contributor → update-post / set-post-meta on another's post 403
contributor → bulk-posts delete of 2 admin-owned posts processed=0, failed=2 — posts verified still present
author → delete-media (another's attachment) 403
shop_manager → reset-password on admin 403
shop_manager → update-user role=administrator 403
shop_manager → set-user-meta wp_capabilities 403
contributor → list-posts?status=any 200, admin draft not in response

Admin regression — 8/8 unchanged

wc-list-orders, wc-get-order-meta (include_protected), get-post, get-post-meta (private key, value returned), list-posts, list-users, wc-list-order-notes, update-user — all 200, all still returning the data they should.

Two defects the live run caught that the unit suite did not

Both now fixed with regression tests (commit 51d4b5f):

  1. get-post still leaked private meta on published posts. The capability check is correct — read_post is legitimately satisfied for a published post — but collect_post_meta() stripped only three keys, so the whole protected meta map went to any Contributor. Confirmed: _lwsec_secret was returned before, empty meta map after, admin still sees the value.
  2. bulk-posts returned HTTP 500 (ability_invalid_output) once any item failed — failed_ids is declared as integers but the loop pushed objects. Pre-existing and previously rare; the new per-item authorization made it the routine path. bulk-comments had the same latent bug.

Scratch users and posts removed from the test site afterwards. Local gate re-run: phpcs clean, PHPStan L5 no errors, 425/425 tests.

@trueqap
trueqap merged commit 1c9d26c into main Aug 7, 2026
7 checks passed
@trueqap
trueqap deleted the fix/object-level-authorization branch August 7, 2026 08:27
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.

1 participant