Skip to content

My WordPress: scope the term-stats endpoint to posts the caller may read - #806

Merged
epeicher merged 3 commits into
trunkfrom
opensta-154-openstation-broken-access-control-in-the-my-wordpress-per
Sep 12, 2026
Merged

My WordPress: scope the term-stats endpoint to posts the caller may read#806
epeicher merged 3 commits into
trunkfrom
opensta-154-openstation-broken-access-control-in-the-my-wordpress-per

Conversation

@epeicher

@epeicher epeicher commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

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, pending and future posts in the recent list, 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 read capability. 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 included private, future, draft and pending with 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/%d placeholder lists bound through prepare() at both use sites, so Plugin Check's DirectDB taint tracking (which is per-scope) can verify it:

  • public statuses, for everyone (a status registered with public => true keeps counting for all viewers, as it did before);
  • private-flagged statuses with the post type's read_private_posts;
  • the remaining non-internal statuses (draft, pending, future, and any registered workflow status) with edit_others_posts, because core maps reading them to editing them; a scheduled post additionally requires edit_published_posts, mirroring map_meta_cap();
  • the caller's own posts in any of those statuses, since core grants an author read on 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's read permission 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_terms cap, closing the same access-control class for hidden-taxonomy term data (name, description, counts).

docs/hooks-reference.md marks the openstation_my_wordpress_term_stats payload 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:tests

Seven 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 => true status stays visible to subscribers; the recent list caps at five, newest first; and a nav_menu term 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 with counts of draft: 1, private: 1, total: 3; patched, the same request returns only the published post with draft: 0, private: 0, total: 1. Anonymous stays 401, an administrator keeps the full view, and a Subscriber's nav_menu request returns 400 openstation_invalid_taxonomy. Full PHPUnit suite green (3041 tests); phpcs -n clean.

Open WordPress Playground Preview

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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
epeicher merged commit 125e160 into trunk Sep 12, 2026
6 checks passed
@epeicher
epeicher deleted the opensta-154-openstation-broken-access-control-in-the-my-wordpress-per branch September 12, 2026 10:04
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.

2 participants