My WordPress: scope the term-stats endpoint to posts the caller may read - #806
Merged
epeicher merged 3 commits intoSep 12, 2026
Conversation
The per-term stats endpoint authorised on the caller's site-wide read capability, which fits the term (terms are public data), but returned data about the posts inside it: the recent list deliberately included private, future, draft and pending posts with no author scoping and no per-post readability test, and the per-status counts were computed over every status, an oracle for content the caller cannot see. Every post-level query is now scoped to the statuses the caller may read, resolved from the registered status objects and the post type's cap map, plus the caller's own posts; the recent list adds an authoritative per-row read_post gate. Terms of non-viewable taxonomies are no longer served to callers who cannot manage them. The openstation_my_wordpress_term_stats filter doc now marks the payload viewer-dependent and warns against term-keyed caching.
Plugin Check's DirectDB sniff tracks taint per scope, so a SQL fragment returned from a helper function reads as an unescaped parameter even though it is all literal placeholder lists bound through prepare(). Constructing the clause in the callback, from get_post_stati() + implode/array_fill placeholder lists the sniff recognises as safe, keeps the sniff active instead of baselining a security rule. Same clause, same args, behaviour unchanged.
…ol-in-the-my-wordpress-per
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The implementation consistently scopes both counts and recent records, applies authoritative per-post checks, and includes targeted regression tests and documentation.
Pull request overview
Scopes term statistics to taxonomies and posts the current user may read, preventing unpublished-content disclosure.
Changes:
- Filters status counts and recent posts by user capabilities and ownership.
- Rejects hidden taxonomies unless the user can manage their terms.
- Adds PHPUnit security coverage and documents viewer-dependent payloads.
File summaries
| File | Description |
|---|---|
includes/my-wordpress/term-stats.php |
Adds taxonomy and post-level authorization filtering. |
tests/phpunit/tests/myWordpressTermStats.php |
Tests subscriber, author, admin, custom-status, limit, and hidden-taxonomy behavior. |
docs/hooks-reference.md |
Documents permission-sensitive results and caching risks. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
epeicher
deleted the
opensta-154-openstation-broken-access-control-in-the-my-wordpress-per
branch
September 12, 2026 10:04
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.
What it does
Stops
GET /desktop-mode/v1/term-stats/<taxonomy>/<id>from disclosing unpublished posts to low-capability users. A Subscriber no longer receives the titles, IDs, statuses, dates, permalinks and author identities of other users'private,draft,pendingandfutureposts in therecentlist, and the per-status count breakdown no longer reveals how many hidden posts a term holds. Terms of non-viewable taxonomies (nav_menu,link_category, a plugin's internal taxonomy) are also no longer served to callers who cannot manage them.Rationale
The endpoint authorises on the caller's site-wide
readcapability. That is the right gate for the term (terms are public-facing data, as the file docblock argues), but the payload is about the posts inside the term: the recent-posts query deliberately includedprivate,future,draftandpendingwith no author scoping and no per-post readability test, and the counts were computed over every status. Any logged-in Subscriber could therefore read an administrator's unpublished work for any term id, and term ids are enumerable anonymously through core's categories and tags routes.Implementation
The callback builds a prepared SQL fragment admitting only the statuses the caller may read, resolved from the registered status objects and the post type's cap map rather than hardcoded lists, so a plugin's custom status follows its own visibility flags. It is constructed inline, from literal
%s/%dplaceholder lists bound throughprepare()at both use sites, so Plugin Check's DirectDB taint tracking (which is per-scope) can verify it:public => truekeeps counting for all viewers, as it did before);read_private_posts;edit_others_posts, because core maps reading them to editing them; a scheduled post additionally requiresedit_published_posts, mirroringmap_meta_cap();readon their own post whatever its status.The counts query runs over that set, so statuses the viewer cannot read report zero instead of acting as an oracle. The recent list uses the same clause plus an authoritative per-row
current_user_can( 'read_post' )gate (post cache bulk-warmed first), which is also the hook membership plugins use to restrict even published posts; it fetches headroom past five and caps the emitted list at five. The endpoint'sreadpermission gate is unchanged, so the public term profile and published-post aggregates still work for every logged-in user.The taxonomy check now answers exactly like an unregistered taxonomy unless the taxonomy is viewable or the caller holds its
manage_termscap, closing the same access-control class for hidden-taxonomy term data (name, description, counts).docs/hooks-reference.mdmarks theopenstation_my_wordpress_term_statspayload as viewer-dependent and warns against caching the filtered payload under a term-only key.Testing instructions
npm run env:start:tests npm run test:php -- --filter='Tests_OpenStation_MyWordpressTermStats' npm run env:stop:testsSeven new tests pin the model: Subscriber sees no unpublished rows and publish-only counts; an administrator keeps everything; an author sees their own draft but not another author's; a custom
public => truestatus stays visible to subscribers; the recent list caps at five, newest first; and anav_menuterm answers 400 to a Subscriber but 200 to an administrator.Verified end to end on the live wp-env install against the real route: unpatched, a Subscriber's request returned
"Secret admin draft"and"Private: Private admin notes"titles withcountsofdraft: 1, private: 1, total: 3; patched, the same request returns only the published post withdraft: 0, private: 0, total: 1. Anonymous stays401, an administrator keeps the full view, and a Subscriber'snav_menurequest returns400 openstation_invalid_taxonomy. Full PHPUnit suite green (3041 tests);phpcs -nclean.