Skip to content

Return 404 for view-capability-denied REST job listing requests#2942

Merged
donnchawp merged 5 commits intotrunkfrom
fix-rest-content-excerpt-view-cap-2941
May 6, 2026
Merged

Return 404 for view-capability-denied REST job listing requests#2942
donnchawp merged 5 commits intotrunkfrom
fix-rest-content-excerpt-view-cap-2941

Conversation

@donnchawp
Copy link
Copy Markdown
Contributor

@donnchawp donnchawp commented May 5, 2026

Summary

Closes #2941. Follow-up to #2940.

WP core's WP_REST_Posts_Controller only blanks content.rendered / excerpt.rendered for password-protected posts, not for listings denied by job_manager_view_job_listing_capability. The earlier approach in this branch patched the leak by blanking listing fields in prepare_job_listing(), but that left the response envelope (200 status, post id, links, embedded author/term data) visible — confirming the listing's existence — and would have broken Gutenberg in ?context=edit since blanked raw fields overwrite the body on save.

This PR closes the leak at the dispatch layer: view-capability denials short-circuit to a 404 that mirrors WP core's "post not found" shape, so a denied viewer cannot distinguish a restricted listing from a missing one.

Changes

includes/class-wp-job-manager-rest-api.php

  • New gate_view_capability_for_single() registered on rest_request_before_callbacks. For GET and HEAD requests to /wp/v2/job-listings/<id> and child routes (/revisions, /autosaves), returns WP_Error('rest_post_invalid_id', __('Invalid post ID.'), ['status' => 404]) when the current user fails job_manager_user_can_view_job_listing(). String and code mirror core's missing-post error byte-for-byte so the responses are indistinguishable.
  • HEAD is gated alongside GET because WP_REST_Server falls back to the GET handler for HEAD but core's controller returns an empty 200 — without the HEAD branch, status code alone (200 vs 404) would let a denied client probe existence.
  • The gate evaluates view-cap before password-protection. Earlier iterations short-circuited on post_password_required() first, which let listings that were both password-protected AND view-cap-restricted return the standard 200 + content.protected envelope — the envelope itself revealed the listing exists, defeating the indistinguishability goal. Password-form contract still works for view-cap-passing users via the prepare filter downstream.
  • prepare_job_listing() keeps the field-blanking branch as defense-in-depth for any code path that reaches the prepare filter without going through the gate (collections, third-party callers, future surfaces). Added a $bypass_for_editor flag so a user with edit_post on a password-only protected listing keeps raw content/title/editor metadata — otherwise Gutenberg would load empty fields and overwrite the body on save. View-capability denials still get the full strip in this branch (the gate normally 404s them; this is belt-and-braces).

includes/class-wp-job-manager-post-types.php

  • auth_check_can_view_job_listing() (the per-meta auth_view_callback) got the same $bypass_for_editor pattern as prepare_job_listing(). Previously it returned false for any password-protected listing with no edit_post exception — an editor opening a password-protected listing in Gutenberg would get blank meta (_company_name, _job_location, _application, etc.), and saving the post in the editor would overwrite those fields with empty values. That's data loss rather than just a display bug. Now an edit_post-capable user on a password-only protected listing keeps meta access; view-capability denials still strip meta.

tests/php/tests/security/test_class.password-protected-listing-rest.php

  • test_rest_single_returns_404_for_view_cap_denied: anonymous GET on a view-cap-denied listing returns 404 + rest_post_invalid_id, and no listing field (title, body, excerpt sentinels) appears anywhere in the response body.
  • test_rest_single_returns_404_for_view_cap_denied_editor_in_edit_context: an editor with edit_post but lacking the view-cap also gets 404, even with ?context=edit.
  • test_rest_single_returns_404_for_view_cap_denied_head_request: HEAD probe on a view-cap-denied listing also returns 404.
  • test_rest_single_returns_404_for_password_protected_and_view_cap_denied: a listing that is both password-protected AND view-cap-restricted still 404s — the password envelope must not leak through.
  • test_rest_single_preserves_meta_for_password_protected_editor: an editor on a password-protected listing sees _company_name, _job_location, _application meta in ?context=edit (guards the auth_check_can_view_job_listing bypass).
  • test_rest_single_preserves_raw_fields_for_password_protected_editor: an editor on a password-protected listing keeps content.raw / title.raw in ?context=edit so Gutenberg can drive the editor.
  • Option setup wrapped in try { ... } finally { delete_option(...); } so an assertion failure no longer leaks job_manager_view_job_listing_capability to subsequent tests in the suite.

tests/php/tests/security/test_class.capability-restricted-listing-output.php

Test plan

  • make lint — clean
  • make test — 470 tests, 1924 assertions, 3 skipped (geocode tests requiring WPJM_PHPUNIT_GOOGLE_GEOCODE_API_KEY)
  • GET and HEAD on a view-cap-denied listing return 404 + rest_post_invalid_id, body contains no listing identifiers
  • GET on a view-cap-denied listing's /revisions and /autosaves sub-routes also return 404
  • A listing that is both password-protected and view-cap-restricted returns 404 (no content.protected envelope leak)
  • GET on a password-protected listing (no view-cap) still returns 200 with content.protected = true (password-form contract preserved)
  • An editor with edit_post opens a password-protected listing in ?context=edit and sees the raw title, content, and meta (Gutenberg-editable; saving does not zero meta)
  • Front-end smoke test: with view-cap = "Editor", a subscriber sees no jobs on the archive and gets the "no permission to view this listing" template on a single-listing URL.

Release Notes

  • Fixes a REST API information disclosure where the body, excerpt, and existence of listings restricted by view-capability were exposed to denied viewers. Restricted listings now return 404 indistinguishable from a missing post, including HEAD probes and listings that are also password-protected.
  • Fixes a data-loss bug where editors opening a password-protected listing in the block editor would save empty meta values (location, company name, application target).

Plugin build for b6149da
📦 Download plugin zip
▶️ Open in playground

donnchawp added 3 commits May 5, 2026 16:26
Closes #2941. `WP_REST_Posts_Controller::prepare_item_for_response()` blanks `content.rendered` / `excerpt.rendered` only when `$post->post_password` is set. For job listings denied solely by `job_manager_view_job_listing_capability` (no password), the body and excerpt remained populated even though the rest of the response was already blanked by `prepare_job_listing()`.

Extend the existing `if ( $is_blocked )` branch to also blank both rendered fields and set `content.protected=true`, mirroring the password-branch contract. Add a regression test for the view-cap branch.

Also adds a regression test for the widget cache bypass shipped in #2940: when view capability is configured, a capable viewer's widget render must not be served from `WP_Widget`'s shared cache to a denied viewer.
…text

A user with edit_post on a job listing but lacking the configured
view-capability could request `?context=edit` and read `title.raw` /
`content.raw` / `excerpt.raw` — the rendered fields were already gated
but the raw siblings were not. Blank them in the same is_blocked branch.
The previous approach blanked title / content / excerpt fields in the
REST response body, which closed the obvious leak but kept the response
envelope (status 200, post id, links, embedded data) visible — revealing
that a listing exists at that ID. It also broke Gutenberg in
?context=edit, since blanked raw fields would overwrite the body on
save.

Gate view-capability denial at rest_request_before_callbacks instead,
returning the same WP_Error('rest_post_invalid_id', 404) shape WP core
uses for missing posts so existence is not revealed. Covers the item
route plus child routes (/revisions, /autosaves) where the parent body
could otherwise surface.

Password-protected listings stay on the WP password-form contract
(200 + content.protected = true) and skip the new gate; the existing
prepare_job_listing blanking remains as defense-in-depth, with a
bypass for users who can edit_post so Gutenberg can load raw fields
on a password-protected listing they legitimately need to edit.

Tests updated to assert 404 + rest_post_invalid_id, with option setup
moved to try/finally so an assertion failure no longer leaves the
view-cap option set across the rest of the suite. New regression test
for the editor / password-protected case.
@donnchawp donnchawp changed the title Blank REST content/excerpt for view-capability-denied listings Return 404 for view-capability-denied REST job listing requests May 6, 2026
donnchawp added 2 commits May 6, 2026 12:57
Two follow-on fixes from adversarial review of the 404 gate:

1. `gate_view_capability_for_single()` previously short-circuited on
   `post_password_required()` before the view-cap check, so listings
   that were both password-protected AND view-cap-restricted returned
   the standard password-protected envelope (200 + content.protected).
   That envelope itself revealed the listing exists at that ID,
   defeating the indistinguishability the gate was meant to provide.
   Drop the password short-circuit and gate purely on view-cap; the
   password contract is still preserved for view-cap-passing users via
   the prepare filter downstream.

2. `auth_check_can_view_job_listing()` (the per-meta `auth_view_callback`)
   returned false for any password-protected listing with no edit_post
   exception. An editor opening a password-protected listing in
   Gutenberg got blank meta (location, company name, application
   target, etc.); saving the post overwrote those values with empty
   strings — data loss rather than just a display bug. Mirror the
   `$bypass_for_editor` pattern from `prepare_job_listing()`: a user
   with edit_post on a password-only protected listing keeps meta
   access. View-capability denials still strip meta.

Regression tests cover the doubly-restricted 404 case and the
editor-meta-preservation case for password-protected listings.
WP_REST_Server falls back to the registered GET handler when no HEAD
handler exists, but `$request->get_method()` still reports `HEAD`. The
gate's previous `'GET' !== $method` guard let HEAD through. Core's
`WP_REST_Posts_Controller::prepare_item_for_response()` returns an
empty 200 for HEAD without running the prepare filter, so a denied
viewer could distinguish a restricted listing from a missing one by
status code alone — the same defeat-by-probe class as the
password+view-cap leak just fixed.

Allow both `GET` and `HEAD` through the gate so HEAD probes 404
identically to GET. Regression test exercises the HEAD path.
@donnchawp donnchawp self-assigned this May 6, 2026
@donnchawp donnchawp merged commit 79b3973 into trunk May 6, 2026
35 checks passed
@donnchawp donnchawp deleted the fix-rest-content-excerpt-view-cap-2941 branch May 6, 2026 12:54
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.

REST: content.rendered and excerpt.rendered leak for view-capability-denied listings

1 participant