Skip to content

fix(abilities): enforce per-object post capabilities in upsert-post - #3933

Merged
chubes4 merged 2 commits into
mainfrom
fix/upsert-post-caps
Nov 12, 2026
Merged

chubes4 merged 2 commits into
mainfrom
fix/upsert-post-caps

Conversation

@chubes4

@chubes4 chubes4 commented Nov 11, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to a security advisory. datamachine/upsert-post was authorized by the chat action alone. Its execute path then wrote whatever post type, status, author, target post, meta and terms the caller supplied, without any WordPress object capability checks. This PR authorizes each write against WordPress capabilities for the acting principal.

datamachine/upsert-post

  • Insert: needs the post type's create_posts. publish, future and private also need publish_posts, and so does creating parent stubs, because stubs are published. Unregistered post types are limited to principals with manage_options.
  • Update: needs edit_post on the target, whether it comes from post_id, slug or identity meta. The check runs in the permission callback and again at write time against the post that was actually resolved. Only a shell allocated by the request's own identity reservation skips it. A status change to publish still needs publish_posts, and trashing needs delete_post.
  • Other authors and protected meta: a post_author other than the acting user needs edit_others_posts. Protected (underscore-prefixed) meta_input keys and the identity_meta key need it too.
  • Terms: assigning terms needs assign_terms on the taxonomy. Unknown terms are only created when the caller has edit_terms.
  • Errors: refusals return upsert_post_forbidden (HTTP 403).

PermissionHelper::can_wp_capability()

New principal-aware helper for primitive and meta capabilities. It uses the same trusted contexts as can(): WP-CLI subject to datamachine_cli_bypass_permissions, Action Scheduler queue runs, and system context.

  • Agent context: checks the agent owner's capabilities. An active capability ceiling must allow every primitive capability that the meta capability maps to. An agent acting for a user never gets more than that user's capabilities.
  • No admin shortcut: unlike can(), manage_options is not treated as a blanket grant. WordPress capability mapping decides.

Sibling chat-gated abilities reviewed

Ability Finding Change
write-self-memory Wrote any file with no editability check. Protected or capability-gated memory files, such as the shared site rules file, could be written. Applies the same MemoryFileRegistry::is_editable() contract as the regular memory write path. The check uses the same filename normalization as the store, so variant spellings can't get around it.
sign-pending-action-resolution Minted approve/reject URLs for any pending action ID. Signed URLs carry resolution authority on their own. Only signs actions inside the caller's pending-action scope (PendingActionScope). Managers keep operator-wide reach.
create-agent Non-admins are forced to be the owner and the per-user limit applies. Caller-supplied config only affects the caller's own agent, and its tool execution stays within the owner's capabilities. None
submit/get/retry/cancel-delegated-operation The permission callback only checks identity, by design. The action owner's mandatory authorize callback runs on every phase and receives the actor. None

Compatibility

  • Trusted contexts (CLI, Action Scheduler imports, system context) keep working unchanged.
  • Callers acting as administrators keep working unchanged.
  • Callers that relied on chat-only access for lower roles will now get 403s where WordPress would also deny.

Tests

  • New UpsertPostPermissionTest (SQLite-safe), covering:
    • A subscriber with chat is denied insert, update and publish.
    • An author can create their own draft and publish. They can't edit someone else's post by ID or slug, can't set another author, and can't set protected meta.
    • An editor can edit others' posts.
    • A contributor can create pending posts but can't publish, schedule or make private, and can't publish their own pending post.
    • The agent token capability ceiling bounds the owner's capabilities.
    • System context still works.
    • Logged-out callers are denied.
  • UpsertPostAbilityTest now runs as an administrator.
  • Smoke coverage added for:
    • protected memory files refused by write-self-memory
    • out-of-scope signing refused, with the owner still able to sign
  • Results:
    • SQLite PHPUnit full suite: OK (1776 tests, 55 skipped).
    • Filtered run (UpsertPostAbilityTest|UpsertPostPermissionTest|PermissionHelperTest|MultiAgentScoping|SelfServiceAgentCreation): 77/77.
    • UpsertPostAbilityTest normally runs only on MySQL, which wasn't available locally. Forced onto SQLite, it showed the same 20 SQLite-only failures on this branch as on main. CI will run it on MySQL.
    • Standalone smokes: email-multi-inbox-forward-smoke fails identically on main. All others pass.

AI assistance

This PR was written by an AI agent (Claude) via Extra Chill Bot.

homeboy-ci Bot added 2 commits November 11, 2026 16:48
The upsert-post ability was gated only by the chat action. Authorize the
requested write against WordPress object capabilities, bounded by the
acting principal and any agent capability ceiling:

- inserts require the post type's create_posts; publish, future and
  private (and parent stub creation) require publish_posts
- updates require edit_post on the resolved target (by ID, slug or
  identity), delete_post when trashing
- a different post_author or protected meta requires edit_others_posts
- taxonomy assignment requires assign_terms; new terms need edit_terms

Add PermissionHelper::can_wp_capability() for principal-aware checks of
primitive and meta capabilities.

Also harden two sibling chat-gated abilities:
- write-self-memory honors memory file editability, so protected files
  such as site-wide rules cannot be written through it
- sign-pending-action-resolution only signs actions inside the caller's
  pending-action scope
The test sets the protected `_source` identity key, which the tightened
upsert-post policy reserves for principals holding edit_others_posts.
Run it as an editor (still non-admin) so it keeps proving the acting
user becomes the shell author; author denial of protected meta stays
covered by UpsertPostPermissionTest.
@chubes4
chubes4 merged commit 5f672d8 into main Nov 12, 2026
37 of 38 checks passed
@chubes4
chubes4 deleted the fix/upsert-post-caps branch November 12, 2026 23:55
chubes4 pushed a commit that referenced this pull request Nov 14, 2026
Recycled test post IDs can land on 3, the default privacy policy page,
so core requires manage_privacy_options (super admin on multisite) to
edit it and the per-object upsert-post check from #3933 denies the
site-admin test user. Closes #3944.
chubes4 added a commit that referenced this pull request Nov 14, 2026
… tests (#3942)

* test(events): use relative future dates in EventUpsertTitleNoiseTest

Hardcoded 2026-11-12..17 dates fell into the past and tripped the upsert
past-event guard. Closes #3941.

* test(events): clear privacy-policy page option in upsert test setup

Recycled test post IDs can land on 3, the default privacy policy page,
so core requires manage_privacy_options (super admin on multisite) to
edit it and the per-object upsert-post check from #3933 denies the
site-admin test user. Closes #3944.

---------

Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
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