fix: authorize every read against its target object (#148) - #149
Merged
Conversation
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
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
marked this pull request as ready for review
August 24, 2026 20:34
Contributor
Author
|
CI: red on the inherited #145 failure only. All six PHPUnit cells report That is #145 — red on Not claiming green. This branch cannot go green until #145 is fixed. |
7 tasks
4 tasks
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
4 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_postagainst it, and every list path filters what it enumerates. The Unsplash link inreadme.txtgains 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:Correct, and wider than the one ability. Read-tier abilities pass
$cap = 'read'toSaddle_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 requiresis_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, soget-post,get-page,get-media,get-blocksandlist-post-revisionsroute through the same check thatlint-page,render-nodeandverify-pagealready used. The byte-identical inline clone inblocks.phpis deleted.get-preview-urlstays deliberately stricter (edit_postfor anything unpublished — it mints an unauthenticated URL) with a docblock saying so.Password-protected posts refuse rather than blank.
map_meta_capnever consultspost_password, soread_postalone waves these through. Core blanks the content; Saddle returns rawpost_content, which core only ever hands out in the edit context — so the threshold is core's ownedit_post. Refusing rather than blanking because Saddle's reader feeds a writer on the same id: an agent handedcontent: ""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 theinternalstatuses (register_post_status()derivesexclude_from_searchfrominternal, notprotected), 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 requestedprivate. Against'any'both$r_statusand$p_statusare 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'sedit_posts(sanitize_post_statuses()), andcollection()— the single chokepoint all three list paths already shared — drops rows failingread_post(get_items()). Both are needed: an Author holdsedit_postsand may legitimately ask for drafts, and must still not receive someone else's. There is a test for exactly that case.totalcounts dropped rows, and says so. Same inconsistency core lives with; recounting means a second unbounded query. What Saddle adds is anoteon 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.comsits behind bot protection returning 401 to any user agent containingMozillaand 200 otherwise — deterministic, three runs each.https://unsplash.com/privacybehaves 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-pagereturn 403 for a target the connected account cannot read.saddle_password_protected(403) unless the account canedit_postit.list-posts/list-pagesreject astatusthe account may not query (saddle_forbidden_status, 403);status: anynow means "any status this account may see".itemsthanper_pagewhiletotalcounts the omitted rows, with anoteexplaining it.list-post-revisionsrequiresedit_poston 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 onlist_users, PII behindedit_users);get-option/list-options(admin tier,manage_options, plusguard_option());get-template/get-global-styles/list-saved-patterns(public-facing presentation; the pattern query ispublishonly); Skills and Memory (bothpublishonly, owner-authored instructions meant for any connected agent by design).Testing
composer test— 643 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 errorsnpm run build— committed bundle byte-unchanged (no JS in this diff).potregenerated — 6 new msgids, 0 removed; it was stale againphp scripts/revendor-wp-mcp.php --checkclean; the six execution functions grep clean outside the vendored adapterVerified in a real install over real HTTP, two Application Passwords on one site:
get-poston a private postsaddle_forbiddenget-poston a draftsaddle_forbiddenget-mediaon that post's uploadsaddle_forbiddenlist-post-revisionssaddle_forbiddenlist-posts status=draftsaddle_forbidden_statuslist-posts(default)list-mediaPlugin 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 inclass-saddle-mcp-compat.php, two dynamic hook names inclass-saddle-integration-engine.php. TheTested up toerror from the last submission is gone.Zip verified from the inside: no
class-saddle-updater.php, noclass-saddle-ecosystem.php, notests//dist// root*.md/ dotfiles,Stable tagstill1.0.0, outbound HTTP limited to the loopback self-check, Unsplash, and the OAuth client-metadata fetch.CI note:
mainhas been red since 2026-08-21 onSaddle_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.