Commit dc552e0
authored
* test(security): pin read-tier disclosure of unreadable content
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
* fix(abilities): authorize the object on every single-item read
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
* fix(abilities): stop the list paths enumerating unreadable content
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
* docs(oauth): say why each public route is public
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
* docs: unsplash links that resolve, changelog, security rule, CI note
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
* docs: session log for the WordPress.org review round 2
Refs #148
1 parent d20573c commit dc552e0
11 files changed
Lines changed: 1448 additions & 160 deletions
File tree
- .claude/skills/wp-security-rules
- includes
- abilities
- oauth
- languages
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
| 36 | + | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| |||
141 | 142 | | |
142 | 143 | | |
143 | 144 | | |
| 145 | + | |
| 146 | + | |
144 | 147 | | |
145 | 148 | | |
146 | 149 | | |
| |||
261 | 264 | | |
262 | 265 | | |
263 | 266 | | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
264 | 329 | | |
265 | 330 | | |
266 | 331 | | |
| |||
271 | 336 | | |
272 | 337 | | |
273 | 338 | | |
274 | | - | |
| 339 | + | |
| 340 | + | |
275 | 341 | | |
276 | 342 | | |
277 | 343 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
595 | 595 | | |
596 | 596 | | |
597 | 597 | | |
598 | | - | |
| 598 | + | |
599 | 599 | | |
600 | 600 | | |
601 | 601 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
2 | 23 | | |
3 | 24 | | |
4 | 25 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
801 | 801 | | |
802 | 802 | | |
803 | 803 | | |
804 | | - | |
805 | | - | |
806 | | - | |
807 | | - | |
808 | | - | |
809 | | - | |
810 | | - | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
811 | 807 | | |
812 | 808 | | |
813 | 809 | | |
| |||
0 commit comments