Skip to content

fix: authorize every read against its target object (#148) - #149

Merged
ifahimreza merged 6 commits into
mainfrom
fix/148-read-object-authorization
Aug 24, 2026
Merged

fix: authorize every read against its target object (#148)#149
ifahimreza merged 6 commits into
mainfrom
fix/148-read-object-authorization

Conversation

@ifahimreza

@ifahimreza ifahimreza commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #148

What

WordPress.org rejected the 1.0.0 submission with two findings. This PR fixes both.

The substantive one: read-tier abilities authorized the caller but never the object. Every read ability now resolves its target through one shared helper that checks read_post against it, and every list path filters what it enumerates. The Unsplash link in readme.txt gains a companion URL that resolves for their tooling.

Nothing changes for the normal setup. An administrator Application Password — how Saddle is actually connected — passes every new check, and the response shape is byte-identical to before.

Why

Their finding, on saddle/get-media:

The callback only checks the generic read capability, while get_media returns any attachment's metadata and URL without a per-attachment read_post authorization check.

Correct, and wider than the one ability. Read-tier abilities pass $cap = 'read' to Saddle_Capabilities::permission() — a capability every logged-in user holds, including a Subscriber. Any logged-in user can mint a core Application Password, and the MCP route only requires is_user_logged_in(). So a Subscriber-level credential reached the whole read surface: other authors' drafts, private posts, password-protected content, revision histories, and all of it enumerable.

The write side has guarded against exactly this since day one (authorize_write(), rule 4 of the wp-security-rules skill). The read side had the same idea implemented three times and applied to half the abilities.

How

One helper, not a fourth copy. Saddle_Abilities::require_readable_post() gains an input-key and post-type parameter, so get-post, get-page, get-media, get-blocks and list-post-revisions route through the same check that lint-page, render-node and verify-page already used. The byte-identical inline clone in blocks.php is deleted. get-preview-url stays deliberately stricter (edit_post for anything unpublished — it mints an unauthenticated URL) with a docblock saying so.

Password-protected posts refuse rather than blank. map_meta_cap never consults post_password, so read_post alone waves these through. Core blanks the content; Saddle returns raw post_content, which core only ever hands out in the edit context — so the threshold is core's own edit_post. Refusing rather than blanking because Saddle's reader feeds a writer on the same id: an agent handed content: "" concludes the page is empty and "fixes" it.

Lists need two controls, and perm => 'readable' is neither. Read against core rather than assumed:

  • post_status => 'any' excludes only the internal statuses (register_post_status() derives exclude_from_search from internal, not protected), so it returns every author's drafts, pending, future and private.
  • 'perm' => 'readable' is consulted at exactly one place (class-wp-query.php:2689) and applies only to an explicitly requested private. Against 'any' both $r_status and $p_status are empty, so it is a complete no-op. It is deliberately not used here — a query var that looks like the control but isn't is worse than none.

So, mirroring what core's REST controllers actually do: status_filter() gates the requested status on the post type's edit_posts (sanitize_post_statuses()), and collection() — the single chokepoint all three list paths already shared — drops rows failing read_post (get_items()). Both are needed: an Author holds edit_posts and may legitimately ask for drafts, and must still not receive someone else's. There is a test for exactly that case.

total counts dropped rows, and says so. Same inconsistency core lives with; recounting means a second unbounded query. What Saddle adds is a note on the response, because an agent handed a short page with no explanation retries it. Never fires for an administrator.

The Unsplash URL is a false positive, handled anyway. unsplash.com sits behind bot protection returning 401 to any user agent containing Mozilla and 200 otherwise — deterministic, three runs each. https://unsplash.com/privacy behaves identically and was not flagged, so a URL swap alone leaves a second landmine. The canonical terms and privacy URLs stay (the disclosure needs both; no mirror of the privacy policy exists) and Unsplash's own help-centre guidelines URL — 200 to every user agent — is added alongside.

Breaking

No public hook or filter changes. Five agent-visible behaviours do, none of them for an administrator credential:

  • get-post / get-page / get-media / get-blocks / lint-page / render-node / verify-page return 403 for a target the connected account cannot read.
  • Password-protected content returns saddle_password_protected (403) unless the account can edit_post it.
  • list-posts / list-pages reject a status the account may not query (saddle_forbidden_status, 403); status: any now means "any status this account may see".
  • Listings may return fewer items than per_page while total counts the omitted rows, with a note explaining it.
  • list-post-revisions requires edit_post on the parent (matching core) and accepts post and page only; a CPT id that previously returned an empty list now returns 404.

Considered and left alone

get-user / list-users (read tier but gated on list_users, PII behind edit_users); get-option / list-options (admin tier, manage_options, plus guard_option()); get-template / get-global-styles / list-saved-patterns (public-facing presentation; the pattern query is publish only); Skills and Memory (both publish only, owner-authored instructions meant for any connected agent by design).

Testing

  • composer test643 tests, 0 failures (612 before, 31 new). Every new case was run against the unfixed code first and confirmed failing for the reason it names.

  • composer lint — 0 errors

  • npm run build — committed bundle byte-unchanged (no JS in this diff)

  • .pot regenerated — 6 new msgids, 0 removed; it was stale again

  • php scripts/revendor-wp-mcp.php --check clean; the six execution functions grep clean outside the vendored adapter

  • Verified in a real install over real HTTP, two Application Passwords on one site:

    call subscriber administrator
    get-post on a private post 403 saddle_forbidden full content
    get-post on a draft 403 saddle_forbidden full content
    get-media on that post's upload 403 saddle_forbidden full metadata
    list-post-revisions 403 saddle_forbidden 200
    list-posts status=draft 403 saddle_forbidden_status returns the draft
    list-posts (default) published only every status
    list-media 25 items / total 26 / note present 26 items / total 26 / no note key
  • Plugin Check against the built zip — extracted zip staged under a folder named exactly saddle (never the symlinked dev tree), then restored. 0 errors. Three warnings, all pre-existing and previously recorded: one vendored-adapter hook name in class-saddle-mcp-compat.php, two dynamic hook names in class-saddle-integration-engine.php. The Tested up to error from the last submission is gone.

  • Zip verified from the inside: no class-saddle-updater.php, no class-saddle-ecosystem.php, no tests/ / dist/ / root *.md / dotfiles, Stable tag still 1.0.0, outbound HTTP limited to the loopback self-check, Unsplash, and the OAuth client-metadata fetch.

CI note: main has been red since 2026-08-21 on Saddle_Skills_Test::test_the_playbook_adapts_step_two_to_a_classic_theme (issue #145, CI-only, does not reproduce locally). This branch inherits it. That failure is unrelated to this diff.

Red on purpose. Every read-tier ability passes `read` to
Saddle_Capabilities::permission(), a capability every logged-in
Subscriber holds, and half the read surface never re-checks the target.
These 15 failures are the disclosure itself:

- get-media returns attachments on draft and private parents
- get-post/get-page return raw content for drafts, private posts and
  password-protected posts
- list-post-revisions returns the edit history of any post
- list-posts/list-pages/search-content enumerate every author's drafts
- list-media enumerates attachments of private posts

The 16 passing cases are the other half of the contract: an
administrator credential — the normal Saddle setup — must be unaffected,
and read_post on an attachment must keep following post_parent.

Refs #148
@ifahimreza ifahimreza linked an issue Aug 24, 2026 that may be closed by this pull request
7 tasks
The read tier's capability is `read`, which every logged-in Subscriber
holds, so the permission callback proves the caller may read something
and never that they may read this. get-post, get-page, get-media and
list-post-revisions resolved their target and returned it unchecked.

require_readable_post() already did this job for lint-page, render-node
and verify-page. It now takes the input key and the accepted post types
as arguments, so those four route through the same funnel instead of
gaining a fifth copy of the check, and the byte-identical inline clone in
get_blocks() collapses into it too. Defaults match the old hardcoded
values, so the three original callers are unchanged.

Two things the shared check gains:

- A password clause. map_meta_cap never consults post_password, so
  read_post alone waves a protected post through. Saddle returns raw
  post_content, which core only hands out in the edit context, so the
  threshold is core's own edit_post. It refuses rather than blanking:
  this reader feeds a writer on the same id, and an agent handed an
  empty body concludes the page needs rebuilding.
- require_id(), so a malformed post_id is a 400 naming the field rather
  than a 404 claiming the post does not exist.

list-post-revisions additionally requires edit_post on the parent, which
is what WP_REST_Revisions_Controller requires — being able to read a post
is not being able to read the drafts it went through.

get-preview-url stays deliberately outside the helper: it mints an
unauthenticated URL, so unpublished content needs a higher bar than "may
read". Its docblock now says so, so nobody collapses it later.

Refs #148
list-posts, list-pages, search-content and list-media returned every
author's drafts and private posts, and every attachment hanging off one,
to any read-tier connection.

WP_Query does not gate this by itself. `post_status => 'any'` excludes
only the two internal statuses, because register_post_status() derives
exclude_from_search from `internal` and not from `protected` — so draft,
pending, future and private all come back. And `'perm' => 'readable'` is
not the fix people assume: it is consulted in one branch, applies only to
an explicitly requested `private`, and is a complete no-op against `any`,
where the arrays it filters are both empty. It is deliberately not used
here; a query var that looks like the control but isn't is worse than
none, and there is a comment saying so.

So the two controls core's own REST layer uses, both of them:

- status_filter() gates the requested status on the post type's
  edit_posts, as sanitize_post_statuses() does. An explicit forbidden
  status is refused by name rather than silently emptied; the default
  `any` narrows to `publish` instead, because refusing the default would
  break read-only listing entirely for a legitimate connection.
- collection() — already the single chokepoint for all three listings —
  drops rows failing read_post, as get_items() does. This is the only
  control that reaches list-media at all, since attachments carry
  `inherit` and the status gate cannot see through it.

Both are needed: an author holds edit_posts and may legitimately ask for
drafts, and must still not receive another author's. There is a test for
exactly that case.

Dropped rows leave `total` counting items that were not returned — the
same inconsistency core accepts, because recounting means a second
unbounded query. What Saddle adds is a note on the response, because an
agent handed a short page with no explanation retries it. It never fires
for an administrator, so that response shape is unchanged.

Refs #148
Four routes carry permission_callback => '__return_true' with no comment
saying why, and they are exactly what a reviewer grepping for that string
lands on. The reasons were already written down in the wp-security-rules
skill; this moves them into the code, in the style /auth-probe already
uses. No behaviour change.

Refs #148
readme: unsplash.com sits behind bot protection that answers 401 to any
user agent containing "Mozilla" and 200 to anything else, which is why
the review flagged the API Terms URL as dead. It is not — but their
checker cannot see it, and https://unsplash.com/privacy behaves the same
way and was not flagged, so swapping one URL would leave a second
landmine. Both canonical links stay, because the external-services
disclosure needs terms and privacy and no mirror of the privacy policy
exists. Unsplash's own help-centre guidelines URL, which answers 200 to
everything, is added alongside.

Also a changelog entry for the read-authorization fix, in the same
plain-language voice as its neighbours.

wp-security-rules gains rule 12, the read-side mirror of rule 4: read
tier means current_user_can('read'), which a Subscriber holds, so the
permission callback proves the caller is signed in and nothing else. It
names require_readable_post() as the single funnel and records the two
core behaviours this leans on — read_post resolving an attachment's
status through post_parent, and map_meta_cap never consulting
post_password — plus why 'perm' => 'readable' is not a control.

CLAUDE.md said this repo has no CI workflows. It has had
.github/workflows/ci.yml running composer lint and composer test for a
while, so the instruction to never claim CI passed was telling agents to
ignore a real signal. Corrected, and pointed at the harder case: report
a red check even when the failure predates the branch.

.pot regenerated — 6 new msgids, 0 removed, and it was stale again.

Refs #148
@ifahimreza
ifahimreza marked this pull request as ready for review August 24, 2026 20:34
@ifahimreza

Copy link
Copy Markdown
Contributor Author

CI: red on the inherited #145 failure only.

All six PHPUnit cells report Tests: 643, Assertions: 2574, Failures: 1, and the one failure is the same on every cell:

1) Saddle_Skills_Test::test_the_playbook_adapts_step_two_to_a_classic_theme

That is #145 — red on main since 2026-08-21, CI-only, does not reproduce locally. Every one of the 31 new tests in this PR passes on PHP 8.1/8.2/8.3 × WP 6.9/latest. PHPCS passes.

Not claiming green. This branch cannot go green until #145 is fixed.

@ifahimreza
ifahimreza merged commit dc552e0 into main Aug 24, 2026
1 of 7 checks passed
@ifahimreza
ifahimreza deleted the fix/148-read-object-authorization branch August 24, 2026 21:01
ifahimreza added a commit that referenced this pull request Aug 26, 2026
The get-media finding is fixed (#149), but the fix is on the execute path
and the reviewer quoted the permission_callback line — so an automated
re-scan quotes the same line again and reads it the same way.

Makes the two-layer model legible at the registration site: a block note at
the top of core-content.php saying the permission_callback gates the TOOL
and require_readable_post()/collection() authorize the OBJECT, plus a
pointer on each id-taking read and each listing. Says why the object check
is on the execute path rather than in the gate — denial_reason() and
is_callable_now() are input-free by construction, and an unexplained "no"
is what puts an agent into a retry loop. get-preview-url's comment records
that it is deliberately stricter than the funnel, not outside it.

Comments only; no behaviour change.

WPORG-SUBMISSION.md gains the round-2 history entry and two reusable
answers: §13 the authorization model, what each control does, the
divergence from core in `total`, and how it was verified; §14 the Unsplash
401 with the Anubis reproduction and the table showing every unsplash.com
path behaves the same — including the /privacy link the checker did not
flag, which is why swapping the flagged URL fixes nothing. Draft reply
appended; not sent.

Refs #161
ifahimreza added a commit that referenced this pull request Aug 26, 2026
The get-media finding is fixed (#149), but the fix is on the execute path
and the reviewer quoted the permission_callback line — so an automated
re-scan quotes the same line again and reads it the same way.

Makes the two-layer model legible at the registration site: a block note at
the top of core-content.php saying the permission_callback gates the TOOL
and require_readable_post()/collection() authorize the OBJECT, plus a
pointer on each id-taking read and each listing. Says why the object check
is on the execute path rather than in the gate — denial_reason() and
is_callable_now() are input-free by construction, and an unexplained "no"
is what puts an agent into a retry loop. get-preview-url's comment records
that it is deliberately stricter than the funnel, not outside it.

Comments only; no behaviour change.

WPORG-SUBMISSION.md gains the round-2 history entry and two reusable
answers: §13 the authorization model, what each control does, the
divergence from core in `total`, and how it was verified; §14 the Unsplash
401 with the Anubis reproduction and the table showing every unsplash.com
path behaves the same — including the /privacy link the checker did not
flag, which is why swapping the flagged URL fixes nothing. Draft reply
appended; not sent.

Refs #161
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.

WordPress.org review round 2: per-object read authorization + Unsplash link 401

1 participant