Return 404 for view-capability-denied REST job listing requests#2942
Merged
Return 404 for view-capability-denied REST job listing requests#2942
Conversation
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.
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.
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.
Summary
Closes #2941. Follow-up to #2940.
WP core's
WP_REST_Posts_Controlleronly blankscontent.rendered/excerpt.renderedfor password-protected posts, not for listings denied byjob_manager_view_job_listing_capability. The earlier approach in this branch patched the leak by blanking listing fields inprepare_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=editsince 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.phpgate_view_capability_for_single()registered onrest_request_before_callbacks. ForGETandHEADrequests to/wp/v2/job-listings/<id>and child routes (/revisions,/autosaves), returnsWP_Error('rest_post_invalid_id', __('Invalid post ID.'), ['status' => 404])when the current user failsjob_manager_user_can_view_job_listing(). String and code mirror core's missing-post error byte-for-byte so the responses are indistinguishable.HEADis gated alongsideGETbecauseWP_REST_Serverfalls 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.post_password_required()first, which let listings that were both password-protected AND view-cap-restricted return the standard 200 +content.protectedenvelope — 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_editorflag so a user withedit_poston 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.phpauth_check_can_view_job_listing()(the per-metaauth_view_callback) got the same$bypass_for_editorpattern asprepare_job_listing(). Previously it returnedfalsefor any password-protected listing with noedit_postexception — 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 anedit_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.phptest_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 withedit_postbut 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,_applicationmeta in?context=edit(guards theauth_check_can_view_job_listingbypass).test_rest_single_preserves_raw_fields_for_password_protected_editor: an editor on a password-protected listing keepscontent.raw/title.rawin?context=editso Gutenberg can drive the editor.try { ... } finally { delete_option(...); }so an assertion failure no longer leaksjob_manager_view_job_listing_capabilityto subsequent tests in the suite.tests/php/tests/security/test_class.capability-restricted-listing-output.phptest_recent_jobs_widget_does_not_share_cache_when_view_cap_configured: regression for the widget cache bypass shipped in Harden public output paths for password-protected and capability-restricted listings #2940. With view-capability configured, a capable viewer's render must not write toWP_Widget's shared cache, and a denied viewer must not receive the capable viewer's listing cards.Test plan
make lint— cleanmake test— 470 tests, 1924 assertions, 3 skipped (geocode tests requiringWPJM_PHPUNIT_GOOGLE_GEOCODE_API_KEY)GETandHEADon a view-cap-denied listing return 404 +rest_post_invalid_id, body contains no listing identifiersGETon a view-cap-denied listing's/revisionsand/autosavessub-routes also return 404content.protectedenvelope leak)GETon a password-protected listing (no view-cap) still returns 200 withcontent.protected = true(password-form contract preserved)edit_postopens a password-protected listing in?context=editand sees the raw title, content, and meta (Gutenberg-editable; saving does not zero meta)Release Notes