From 9a0da43524c8cf65c26014ec82620557ded2eca5 Mon Sep 17 00:00:00 2001 From: Donncha O Caoimh <5656673+donnchawp@users.noreply.github.com> Date: Tue, 5 May 2026 16:26:18 +0100 Subject: [PATCH 1/5] Blank content / excerpt for view-capability-denied REST responses 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. --- includes/class-wp-job-manager-rest-api.php | 12 +++- ...s.capability-restricted-listing-output.php | 57 +++++++++++++++++++ ..._class.password-protected-listing-rest.php | 37 ++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/includes/class-wp-job-manager-rest-api.php b/includes/class-wp-job-manager-rest-api.php index 389b51b58..08e07dde2 100644 --- a/includes/class-wp-job-manager-rest-api.php +++ b/includes/class-wp-job-manager-rest-api.php @@ -85,7 +85,8 @@ public static function prepare_job_listing( $response, $post ) { // When the listing is password-protected (and the viewer hasn't unlocked it) or the // view-capability gate denies the viewer, blank out the identifying top-level fields too. WP core - // only gates `content.rendered` / `excerpt.rendered`; for job listings the title / link / + // only gates `content.rendered` / `excerpt.rendered` for the password branch; for the + // view-capability branch core leaves them populated, so we blank them here. Title / link / // slug / featured-media references can themselves carry sensitive information. $is_blocked = post_password_required( $post ) || ! job_manager_user_can_view_job_listing( $post->ID ); @@ -93,6 +94,15 @@ public static function prepare_job_listing( $response, $post ) { if ( isset( $data['title']['rendered'] ) ) { $data['title']['rendered'] = ''; } + if ( isset( $data['content']['rendered'] ) ) { + $data['content']['rendered'] = ''; + } + if ( isset( $data['content'] ) && is_array( $data['content'] ) ) { + $data['content']['protected'] = true; + } + if ( isset( $data['excerpt']['rendered'] ) ) { + $data['excerpt']['rendered'] = ''; + } if ( array_key_exists( 'link', $data ) ) { unset( $data['link'] ); } diff --git a/tests/php/tests/security/test_class.capability-restricted-listing-output.php b/tests/php/tests/security/test_class.capability-restricted-listing-output.php index 8b54b5e8c..54fba1bf8 100644 --- a/tests/php/tests/security/test_class.capability-restricted-listing-output.php +++ b/tests/php/tests/security/test_class.capability-restricted-listing-output.php @@ -90,6 +90,63 @@ public function test_jsonld_still_emits_for_capable_viewer() { $this->assertTrue( wpjm_output_job_listing_structured_data( $post_id ) ); } + /** + * @covers WP_Job_Manager_Widget_Recent_Jobs::widget + * + * Regression for the cache-amplification path. WP_Widget's built-in cache stores rendered + * HTML keyed only by widget instance id. With view capability configured, the per-listing + * template gate makes output viewer-dependent — a capable viewer would prime the cache + * with their listing cards, and a denied viewer would receive them on the next request. + * + * The widget bypasses both `get_cached_widget()` and `cache_widget()` whenever the + * view-capability option is set, so no cross-viewer cache slot is ever written. + */ + public function test_recent_jobs_widget_does_not_share_cache_when_view_cap_configured() { + $post_id = $this->factory->job_listing->create( + [ + 'post_title' => 'sentinel-WIDGETCACHE listing', + ] + ); + + // Capable viewer (admin) renders the widget — must not write the shared cache. + $this->login_as_admin(); + ob_start(); + the_widget( + 'WP_Job_Manager_Widget_Recent_Jobs', + // `remote_position` defaults are conditionally registered on `job_manager_enable_remote_position`; + // pass an explicit value so `widget()` does not warn on PHP 8+ for the unset key. + [ 'remote_position' => 'all' ], + [ 'widget_id' => 'widget_recent_jobs-cache-probe' ] + ); + $admin_html = (string) ob_get_clean(); + $this->assertStringContainsString( "post-{$post_id}", $admin_html, 'Capable viewer must see the listing card.' ); + + // Inspect the shared cache — must be empty for this widget instance. + $cache = wp_cache_get( 'widget_recent_jobs', 'widget' ); + $this->assertTrue( + ! is_array( $cache ) || ! isset( $cache['widget_recent_jobs-cache-probe'] ), + 'Widget cache must NOT be populated when view-capability is configured.' + ); + + // Denied viewer (anonymous) must render fresh and the per-listing template gate must + // suppress the listing card. Without the bypass, the admin's HTML would be served here. + $this->logout(); + ob_start(); + the_widget( + 'WP_Job_Manager_Widget_Recent_Jobs', + // `remote_position` defaults are conditionally registered on `job_manager_enable_remote_position`; + // pass an explicit value so `widget()` does not warn on PHP 8+ for the unset key. + [ 'remote_position' => 'all' ], + [ 'widget_id' => 'widget_recent_jobs-cache-probe' ] + ); + $anon_html = (string) ob_get_clean(); + $this->assertStringNotContainsString( + "post-{$post_id}", + $anon_html, + 'Denied viewer must not receive the capable viewer\'s cached listing card.' + ); + } + /** * Captures the RSS feed output for the job_feed handler. * diff --git a/tests/php/tests/security/test_class.password-protected-listing-rest.php b/tests/php/tests/security/test_class.password-protected-listing-rest.php index 7084bcfe4..e0ca2d4f8 100644 --- a/tests/php/tests/security/test_class.password-protected-listing-rest.php +++ b/tests/php/tests/security/test_class.password-protected-listing-rest.php @@ -210,4 +210,41 @@ public function test_default_feed_query_leaves_non_job_listing_queries_alone() { $this->assertNotSame( false, $query->get( 'has_password' ) ); } + + /** + * @covers WP_Job_Manager_REST_API::prepare_job_listing + * + * Regression for #2941. Core gates `content.rendered` / `excerpt.rendered` for + * password-protected posts but not for posts blocked solely by view capability. Ensure + * `prepare_job_listing()` blanks both branches so a viewer denied by the view-cap option + * does not receive the listing body. + */ + public function test_rest_single_blanks_content_and_excerpt_for_view_cap_denied() { + // Restrict view to a capability anonymous viewers do not have. + update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); + + $post_id = $this->factory->job_listing->create( + [ + 'post_title' => 'View-cap-restricted listing', + 'post_content' => 'sentinel-VIEWCAP-BODY confidential salary $250k', + 'post_excerpt' => 'sentinel-VIEWCAP-EXCERPT confidential excerpt', + ] + ); + $this->logout(); + + $response = $this->get( "/wp/v2/job-listings/{$post_id}" ); + $this->assertResponseStatus( $response, 200 ); + $data = $response->get_data(); + + $this->assertSame( '', $data['title']['rendered'] ?? null, 'Title must be blanked.' ); + $this->assertEmpty( $data['content']['rendered'] ?? '', 'Body content must not surface when view-cap denies the viewer.' ); + $this->assertEmpty( $data['excerpt']['rendered'] ?? '', 'Excerpt must not surface when view-cap denies the viewer.' ); + // Sentinel-level check: the original body / excerpt strings must not appear anywhere in + // the rendered fields, regardless of whether the field is `''`, `null`, or absent. + $this->assertStringNotContainsString( 'VIEWCAP-BODY', (string) ( $data['content']['rendered'] ?? '' ) ); + $this->assertStringNotContainsString( 'VIEWCAP-EXCERPT', (string) ( $data['excerpt']['rendered'] ?? '' ) ); + $this->assertTrue( $data['content']['protected'] ?? false, 'content.protected must be true to mirror the password-protected contract.' ); + + delete_option( 'job_manager_view_job_listing_capability' ); + } } From b6149daba7707686f9546051d89563c9aa713dcd Mon Sep 17 00:00:00 2001 From: Donncha O Caoimh <5656673+donnchawp@users.noreply.github.com> Date: Tue, 5 May 2026 16:55:25 +0100 Subject: [PATCH 2/5] Blank raw title / content / excerpt for view-cap-denied REST edit context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- includes/class-wp-job-manager-rest-api.php | 9 ++++ ..._class.password-protected-listing-rest.php | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/includes/class-wp-job-manager-rest-api.php b/includes/class-wp-job-manager-rest-api.php index 08e07dde2..13a0afaf0 100644 --- a/includes/class-wp-job-manager-rest-api.php +++ b/includes/class-wp-job-manager-rest-api.php @@ -94,15 +94,24 @@ public static function prepare_job_listing( $response, $post ) { if ( isset( $data['title']['rendered'] ) ) { $data['title']['rendered'] = ''; } + if ( isset( $data['title']['raw'] ) ) { + $data['title']['raw'] = ''; + } if ( isset( $data['content']['rendered'] ) ) { $data['content']['rendered'] = ''; } + if ( isset( $data['content']['raw'] ) ) { + $data['content']['raw'] = ''; + } if ( isset( $data['content'] ) && is_array( $data['content'] ) ) { $data['content']['protected'] = true; } if ( isset( $data['excerpt']['rendered'] ) ) { $data['excerpt']['rendered'] = ''; } + if ( isset( $data['excerpt']['raw'] ) ) { + $data['excerpt']['raw'] = ''; + } if ( array_key_exists( 'link', $data ) ) { unset( $data['link'] ); } diff --git a/tests/php/tests/security/test_class.password-protected-listing-rest.php b/tests/php/tests/security/test_class.password-protected-listing-rest.php index e0ca2d4f8..db8d22ddb 100644 --- a/tests/php/tests/security/test_class.password-protected-listing-rest.php +++ b/tests/php/tests/security/test_class.password-protected-listing-rest.php @@ -247,4 +247,51 @@ public function test_rest_single_blanks_content_and_excerpt_for_view_cap_denied( delete_option( 'job_manager_view_job_listing_capability' ); } + + /** + * @covers WP_Job_Manager_REST_API::prepare_job_listing + * + * `?context=edit` also returns `title.raw` / `content.raw` / `excerpt.raw`. A user with + * edit_post on the listing but lacking the view-capability must not receive the raw body + * either — `prepare_job_listing()` must blank all three raw fields in the blocked branch. + */ + public function test_rest_single_blanks_raw_fields_for_view_cap_denied_editor() { + update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); + + // Build a user that can edit this listing (so `?context=edit` is allowed) but lacks + // `manage_options` (so the view-capability gate denies them) and is not the post + // author (so the author short-circuit in `job_manager_user_can_view_job_listing` does + // not apply). The job_listing post type uses a custom capability_type with + // `map_meta_cap`, so we grant the relevant primitive caps explicitly. + $author_id = $this->factory->user->create( [ 'role' => 'author' ] ); + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + $editor = get_user_by( 'id', $editor_id ); + foreach ( [ 'edit_job_listing', 'edit_job_listings', 'edit_others_job_listings', 'edit_published_job_listings', 'read_job_listing', 'read_private_job_listings' ] as $cap ) { + $editor->add_cap( $cap ); + } + + $post_id = $this->factory->job_listing->create( + [ + 'post_author' => $author_id, + 'post_title' => 'Raw-leak-test listing', + 'post_content' => 'sentinel-RAW-BODY confidential salary $250k', + 'post_excerpt' => 'sentinel-RAW-EXCERPT confidential excerpt', + ] + ); + + wp_set_current_user( $editor_id ); + + $response = $this->get( "/wp/v2/job-listings/{$post_id}", [ 'context' => 'edit' ] ); + $this->assertResponseStatus( $response, 200 ); + $data = $response->get_data(); + + $this->assertEmpty( $data['title']['raw'] ?? '', 'title.raw must be blanked.' ); + $this->assertEmpty( $data['content']['raw'] ?? '', 'content.raw must be blanked.' ); + $this->assertEmpty( $data['excerpt']['raw'] ?? '', 'excerpt.raw must be blanked.' ); + $this->assertStringNotContainsString( 'RAW-BODY', (string) ( $data['content']['raw'] ?? '' ) ); + $this->assertStringNotContainsString( 'RAW-EXCERPT', (string) ( $data['excerpt']['raw'] ?? '' ) ); + + wp_set_current_user( 0 ); + delete_option( 'job_manager_view_job_listing_capability' ); + } } From 7deb876d742b17d77bba474e7aea6bd0c1653105 Mon Sep 17 00:00:00 2001 From: Donncha O Caoimh <5656673+donnchawp@users.noreply.github.com> Date: Wed, 6 May 2026 11:32:57 +0100 Subject: [PATCH 3/5] Return 404 for view-capability-denied REST job listing requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- includes/class-wp-job-manager-rest-api.php | 79 ++++++++- ..._class.password-protected-listing-rest.php | 160 +++++++++++------- 2 files changed, 170 insertions(+), 69 deletions(-) diff --git a/includes/class-wp-job-manager-rest-api.php b/includes/class-wp-job-manager-rest-api.php index 13a0afaf0..970621c94 100644 --- a/includes/class-wp-job-manager-rest-api.php +++ b/includes/class-wp-job-manager-rest-api.php @@ -23,6 +23,57 @@ class WP_Job_Manager_REST_API { public static function init() { add_filter( 'rest_prepare_job_listing', [ __CLASS__, 'prepare_job_listing' ], 10, 2 ); add_filter( 'rest_job_listing_query', [ __CLASS__, 'exclude_filled_from_query' ], 10, 2 ); + add_filter( 'rest_request_before_callbacks', [ __CLASS__, 'gate_view_capability_for_single' ], 10, 3 ); + } + + /** + * Returns 404 for GET requests to a single job listing when the current user is denied by + * the `job_manager_view_job_listing_capability` option. Mirrors WP core's "post not found" + * shape so the existence of restricted listings is not revealed. + * + * Password-protected listings are intentionally not gated here — WP's password-form contract + * requires a 200 with `content.protected = true`, which `prepare_job_listing()` produces. + * Author and `preview` short-circuits are inside `job_manager_user_can_view_job_listing()`. + * + * @param mixed $response Result from the dispatched request, prior to invoking the callback. + * @param array $handler Route handler used for the request. + * @param WP_REST_Request $request Request used to generate the response. + * @return mixed + */ + public static function gate_view_capability_for_single( $response, $handler, $request ) { + if ( is_wp_error( $response ) ) { + return $response; + } + if ( 'GET' !== $request->get_method() ) { + return $response; + } + // Match the item route and its children (revisions, autosaves) — all of them can + // surface listing body data and must be gated on the parent post's view capability. + if ( ! preg_match( '#^/wp/v2/job-listings/(?P\d+)(?:/[^?]*)?$#', (string) $request->get_route(), $matches ) ) { + return $response; + } + $post_id = absint( $matches['id'] ); + if ( ! $post_id ) { + return $response; + } + $post = get_post( $post_id ); + if ( ! $post || WP_Job_Manager_Post_Types::PT_LISTING !== $post->post_type ) { + return $response; + } + if ( post_password_required( $post ) ) { + return $response; + } + if ( job_manager_user_can_view_job_listing( $post_id ) ) { + return $response; + } + + return new WP_Error( + 'rest_post_invalid_id', + // String mirrors WP core's WP_REST_Posts_Controller so a denied viewer cannot + // distinguish this 404 from a missing-post 404. + __( 'Invalid post ID.' ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + [ 'status' => 404 ] + ); } /** @@ -83,14 +134,26 @@ public static function prepare_job_listing( $response, $post ) { $data['meta'] = []; } - // When the listing is password-protected (and the viewer hasn't unlocked it) or the - // view-capability gate denies the viewer, blank out the identifying top-level fields too. WP core - // only gates `content.rendered` / `excerpt.rendered` for the password branch; for the - // view-capability branch core leaves them populated, so we blank them here. Title / link / - // slug / featured-media references can themselves carry sensitive information. - $is_blocked = post_password_required( $post ) || ! job_manager_user_can_view_job_listing( $post->ID ); - - if ( $is_blocked ) { + // View-capability denials are normally short-circuited to 404 in + // gate_view_capability_for_single() before we reach this filter. Keep the blanking + // path as defense-in-depth for any code path that still reaches here (collection + // responses, third-party callers re-running this filter), and for password-protected + // listings where the WP contract is 200 + content.protected so clients can render a + // password form. Title / link / slug / featured-media references can themselves carry + // sensitive information so we blank them too. + $is_password_blocked = post_password_required( $post ); + $is_viewcap_blocked = ! job_manager_user_can_view_job_listing( $post->ID ); + $is_blocked = $is_password_blocked || $is_viewcap_blocked; + + // An edit-capable user hitting a *password-only* protected listing legitimately needs + // the raw fields and editor metadata to operate Gutenberg (WP core's controller permits + // the request precisely because of edit_post). WP core itself still blanks + // `content.rendered`, which is enough for the password contract. View-capability + // denials do NOT get this bypass — they are the harder gate. + $bypass_for_editor = $is_password_blocked && ! $is_viewcap_blocked + && current_user_can( 'edit_post', $post->ID ); + + if ( $is_blocked && ! $bypass_for_editor ) { if ( isset( $data['title']['rendered'] ) ) { $data['title']['rendered'] = ''; } diff --git a/tests/php/tests/security/test_class.password-protected-listing-rest.php b/tests/php/tests/security/test_class.password-protected-listing-rest.php index db8d22ddb..63707bcce 100644 --- a/tests/php/tests/security/test_class.password-protected-listing-rest.php +++ b/tests/php/tests/security/test_class.password-protected-listing-rest.php @@ -214,84 +214,122 @@ public function test_default_feed_query_leaves_non_job_listing_queries_alone() { /** * @covers WP_Job_Manager_REST_API::prepare_job_listing * - * Regression for #2941. Core gates `content.rendered` / `excerpt.rendered` for - * password-protected posts but not for posts blocked solely by view capability. Ensure - * `prepare_job_listing()` blanks both branches so a viewer denied by the view-cap option - * does not receive the listing body. + * An editor opening a password-protected listing in the block editor needs the raw fields + * and identifying metadata (title, link, featured-media) to drive Gutenberg — saving + * blanked raw values would overwrite the body. WP core's controller already permits this + * request because of edit_post, and core itself blanks `content.rendered` for the password + * contract. The plugin's extra hardening must back off so it doesn't break legit editing. */ - public function test_rest_single_blanks_content_and_excerpt_for_view_cap_denied() { - // Restrict view to a capability anonymous viewers do not have. - update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); + public function test_rest_single_preserves_raw_fields_for_password_protected_editor() { + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + $editor = get_user_by( 'id', $editor_id ); + foreach ( [ 'edit_job_listing', 'edit_job_listings', 'edit_others_job_listings', 'edit_published_job_listings', 'read_job_listing', 'read_private_job_listings' ] as $cap ) { + $editor->add_cap( $cap ); + } $post_id = $this->factory->job_listing->create( [ - 'post_title' => 'View-cap-restricted listing', - 'post_content' => 'sentinel-VIEWCAP-BODY confidential salary $250k', - 'post_excerpt' => 'sentinel-VIEWCAP-EXCERPT confidential excerpt', + 'post_password' => 'secret', + 'post_title' => 'Editor Edit Test', + 'post_content' => 'sentinel-PWEDIT-BODY confidential salary $250k', + 'post_excerpt' => 'sentinel-PWEDIT-EXCERPT confidential excerpt', ] ); - $this->logout(); - - $response = $this->get( "/wp/v2/job-listings/{$post_id}" ); - $this->assertResponseStatus( $response, 200 ); - $data = $response->get_data(); - $this->assertSame( '', $data['title']['rendered'] ?? null, 'Title must be blanked.' ); - $this->assertEmpty( $data['content']['rendered'] ?? '', 'Body content must not surface when view-cap denies the viewer.' ); - $this->assertEmpty( $data['excerpt']['rendered'] ?? '', 'Excerpt must not surface when view-cap denies the viewer.' ); - // Sentinel-level check: the original body / excerpt strings must not appear anywhere in - // the rendered fields, regardless of whether the field is `''`, `null`, or absent. - $this->assertStringNotContainsString( 'VIEWCAP-BODY', (string) ( $data['content']['rendered'] ?? '' ) ); - $this->assertStringNotContainsString( 'VIEWCAP-EXCERPT', (string) ( $data['excerpt']['rendered'] ?? '' ) ); - $this->assertTrue( $data['content']['protected'] ?? false, 'content.protected must be true to mirror the password-protected contract.' ); + wp_set_current_user( $editor_id ); - delete_option( 'job_manager_view_job_listing_capability' ); + try { + $response = $this->get( "/wp/v2/job-listings/{$post_id}", [ 'context' => 'edit' ] ); + $this->assertResponseStatus( $response, 200 ); + $data = $response->get_data(); + + // `job_listing` does not declare `excerpt` in `supports`, so `$data['excerpt']` + // is never populated by the core controller — only content + title are testable. + $this->assertStringContainsString( 'sentinel-PWEDIT-BODY', (string) ( $data['content']['raw'] ?? '' ), 'Editor must see raw content for password-protected listing.' ); + $this->assertStringContainsString( 'Editor Edit Test', (string) ( $data['title']['raw'] ?? '' ), 'Editor must see raw title for password-protected listing.' ); + } finally { + wp_set_current_user( 0 ); + } } /** - * @covers WP_Job_Manager_REST_API::prepare_job_listing + * @covers WP_Job_Manager_REST_API::gate_view_capability_for_single * - * `?context=edit` also returns `title.raw` / `content.raw` / `excerpt.raw`. A user with - * edit_post on the listing but lacking the view-capability must not receive the raw body - * either — `prepare_job_listing()` must blank all three raw fields in the blocked branch. + * Regression for #2941. A viewer denied by `job_manager_view_job_listing_capability` + * must not receive the listing — REST returns the same 404 + `rest_post_invalid_id` + * shape WP core uses for unknown posts so the listing's existence is not revealed, + * and no listing fields (title / content / excerpt) appear anywhere in the body. */ - public function test_rest_single_blanks_raw_fields_for_view_cap_denied_editor() { + public function test_rest_single_returns_404_for_view_cap_denied() { update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); - // Build a user that can edit this listing (so `?context=edit` is allowed) but lacks - // `manage_options` (so the view-capability gate denies them) and is not the post - // author (so the author short-circuit in `job_manager_user_can_view_job_listing` does - // not apply). The job_listing post type uses a custom capability_type with - // `map_meta_cap`, so we grant the relevant primitive caps explicitly. - $author_id = $this->factory->user->create( [ 'role' => 'author' ] ); - $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); - $editor = get_user_by( 'id', $editor_id ); - foreach ( [ 'edit_job_listing', 'edit_job_listings', 'edit_others_job_listings', 'edit_published_job_listings', 'read_job_listing', 'read_private_job_listings' ] as $cap ) { - $editor->add_cap( $cap ); + try { + $post_id = $this->factory->job_listing->create( + [ + 'post_title' => 'View-cap-restricted listing', + 'post_content' => 'sentinel-VIEWCAP-BODY confidential salary $250k', + 'post_excerpt' => 'sentinel-VIEWCAP-EXCERPT confidential excerpt', + ] + ); + $this->logout(); + + $response = $this->get( "/wp/v2/job-listings/{$post_id}" ); + $this->assertResponseStatus( $response, 404 ); + + $data = $response->get_data(); + $this->assertSame( 'rest_post_invalid_id', $data['code'] ?? null, '404 must use the same code as WP core for missing posts.' ); + + $body = (string) wp_json_encode( $data ); + $this->assertStringNotContainsString( 'View-cap-restricted listing', $body, 'Title must not surface in the 404 body.' ); + $this->assertStringNotContainsString( 'VIEWCAP-BODY', $body, 'Post content must not surface in the 404 body.' ); + $this->assertStringNotContainsString( 'VIEWCAP-EXCERPT', $body, 'Post excerpt must not surface in the 404 body.' ); + } finally { + delete_option( 'job_manager_view_job_listing_capability' ); } + } - $post_id = $this->factory->job_listing->create( - [ - 'post_author' => $author_id, - 'post_title' => 'Raw-leak-test listing', - 'post_content' => 'sentinel-RAW-BODY confidential salary $250k', - 'post_excerpt' => 'sentinel-RAW-EXCERPT confidential excerpt', - ] - ); - - wp_set_current_user( $editor_id ); - - $response = $this->get( "/wp/v2/job-listings/{$post_id}", [ 'context' => 'edit' ] ); - $this->assertResponseStatus( $response, 200 ); - $data = $response->get_data(); - - $this->assertEmpty( $data['title']['raw'] ?? '', 'title.raw must be blanked.' ); - $this->assertEmpty( $data['content']['raw'] ?? '', 'content.raw must be blanked.' ); - $this->assertEmpty( $data['excerpt']['raw'] ?? '', 'excerpt.raw must be blanked.' ); - $this->assertStringNotContainsString( 'RAW-BODY', (string) ( $data['content']['raw'] ?? '' ) ); - $this->assertStringNotContainsString( 'RAW-EXCERPT', (string) ( $data['excerpt']['raw'] ?? '' ) ); + /** + * @covers WP_Job_Manager_REST_API::gate_view_capability_for_single + * + * A user with `edit_post` on the listing but lacking the view-capability must still get + * 404 on a GET — even with `?context=edit`. The previous "blank raw fields" approach + * left the 200 envelope visible (revealing the listing exists); the gate closes that. + */ + public function test_rest_single_returns_404_for_view_cap_denied_editor_in_edit_context() { + update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); - wp_set_current_user( 0 ); - delete_option( 'job_manager_view_job_listing_capability' ); + try { + $author_id = $this->factory->user->create( [ 'role' => 'author' ] ); + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + $editor = get_user_by( 'id', $editor_id ); + foreach ( [ 'edit_job_listing', 'edit_job_listings', 'edit_others_job_listings', 'edit_published_job_listings', 'read_job_listing', 'read_private_job_listings' ] as $cap ) { + $editor->add_cap( $cap ); + } + + $post_id = $this->factory->job_listing->create( + [ + 'post_author' => $author_id, + 'post_title' => 'Raw-leak-test listing', + 'post_content' => 'sentinel-RAW-BODY confidential salary $250k', + 'post_excerpt' => 'sentinel-RAW-EXCERPT confidential excerpt', + ] + ); + + wp_set_current_user( $editor_id ); + + $response = $this->get( "/wp/v2/job-listings/{$post_id}", [ 'context' => 'edit' ] ); + $this->assertResponseStatus( $response, 404 ); + + $data = $response->get_data(); + $this->assertSame( 'rest_post_invalid_id', $data['code'] ?? null, '404 must use the same code as WP core for missing posts.' ); + + $body = (string) wp_json_encode( $data ); + $this->assertStringNotContainsString( 'Raw-leak-test listing', $body, 'Title must not surface even in edit context.' ); + $this->assertStringNotContainsString( 'RAW-BODY', $body, 'Raw body must not surface even in edit context.' ); + $this->assertStringNotContainsString( 'RAW-EXCERPT', $body, 'Raw excerpt must not surface even in edit context.' ); + } finally { + wp_set_current_user( 0 ); + delete_option( 'job_manager_view_job_listing_capability' ); + } } } From 560e8ad1122d89724999210ea374960ce184b3d3 Mon Sep 17 00:00:00 2001 From: Donncha O Caoimh <5656673+donnchawp@users.noreply.github.com> Date: Wed, 6 May 2026 12:57:27 +0100 Subject: [PATCH 4/5] Close password+view-cap probe leak and meta-loss bug for editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- includes/class-wp-job-manager-post-types.php | 18 +++-- includes/class-wp-job-manager-rest-api.php | 19 ++--- ..._class.password-protected-listing-rest.php | 80 +++++++++++++++++++ 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/includes/class-wp-job-manager-post-types.php b/includes/class-wp-job-manager-post-types.php index 1215e7e9f..31f9235e0 100644 --- a/includes/class-wp-job-manager-post-types.php +++ b/includes/class-wp-job-manager-post-types.php @@ -2093,15 +2093,19 @@ public static function auth_check_can_view_job_listing( $allowed, $meta_key, $po return true; } - if ( post_password_required( $post_id ) ) { - return false; - } - - if ( ! job_manager_user_can_view_job_listing( $post_id ) ) { - return false; + $is_password_blocked = post_password_required( $post_id ); + $is_viewcap_blocked = ! job_manager_user_can_view_job_listing( $post_id ); + + // Mirror the bypass in WP_Job_Manager_REST_API::prepare_job_listing(): a user with + // edit_post on a *password-only* protected listing legitimately needs meta access + // to drive Gutenberg — without this, saving the post in the editor overwrites + // `_company_name`, `_job_location`, `_application`, etc. with empty values. + // View-capability denials do NOT get this bypass — they remain the harder gate. + if ( $is_password_blocked && ! $is_viewcap_blocked && user_can( $user_id, 'edit_post', $post_id ) ) { + return true; } - return true; + return ! ( $is_password_blocked || $is_viewcap_blocked ); } /** diff --git a/includes/class-wp-job-manager-rest-api.php b/includes/class-wp-job-manager-rest-api.php index 970621c94..d69b950c6 100644 --- a/includes/class-wp-job-manager-rest-api.php +++ b/includes/class-wp-job-manager-rest-api.php @@ -27,13 +27,17 @@ public static function init() { } /** - * Returns 404 for GET requests to a single job listing when the current user is denied by - * the `job_manager_view_job_listing_capability` option. Mirrors WP core's "post not found" - * shape so the existence of restricted listings is not revealed. + * Returns 404 for GET requests to a single job listing when the current user is + * denied by the `job_manager_view_job_listing_capability` option. Mirrors WP core's + * "post not found" shape so the existence of restricted listings is not revealed. * - * Password-protected listings are intentionally not gated here — WP's password-form contract - * requires a 200 with `content.protected = true`, which `prepare_job_listing()` produces. - * Author and `preview` short-circuits are inside `job_manager_user_can_view_job_listing()`. + * The view-capability check is the only gate. For view-cap-*passing* users on + * password-protected listings the request continues and WP core's controller + + * `prepare_job_listing()` produce the password contract (200 + `content.protected`) + * downstream. View-cap-*failing* users always get 404, even on password-protected + * listings — otherwise the password envelope would itself reveal that the listing + * exists at that ID. Author and `preview` short-circuits live inside + * `job_manager_user_can_view_job_listing()`. * * @param mixed $response Result from the dispatched request, prior to invoking the callback. * @param array $handler Route handler used for the request. @@ -60,9 +64,6 @@ public static function gate_view_capability_for_single( $response, $handler, $re if ( ! $post || WP_Job_Manager_Post_Types::PT_LISTING !== $post->post_type ) { return $response; } - if ( post_password_required( $post ) ) { - return $response; - } if ( job_manager_user_can_view_job_listing( $post_id ) ) { return $response; } diff --git a/tests/php/tests/security/test_class.password-protected-listing-rest.php b/tests/php/tests/security/test_class.password-protected-listing-rest.php index 63707bcce..839893cee 100644 --- a/tests/php/tests/security/test_class.password-protected-listing-rest.php +++ b/tests/php/tests/security/test_class.password-protected-listing-rest.php @@ -211,6 +211,86 @@ public function test_default_feed_query_leaves_non_job_listing_queries_alone() { $this->assertNotSame( false, $query->get( 'has_password' ) ); } + /** + * @covers WP_Job_Manager_REST_API::gate_view_capability_for_single + * + * Regression: when a listing is BOTH password-protected AND view-capability-restricted, + * the gate must still return 404. An earlier version short-circuited on + * `post_password_required()` *before* the view-cap check, leaving these doubly-restricted + * listings to return the standard 200 + `content.protected` envelope — which itself + * confirmed the listing existed at that ID, defeating the indistinguishability goal. + */ + public function test_rest_single_returns_404_for_password_protected_and_view_cap_denied() { + update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); + + try { + $post_id = $this->factory->job_listing->create( + [ + 'post_password' => 'secret', + 'post_title' => 'Double-restricted listing', + 'post_content' => 'sentinel-DOUBLE-BODY confidential', + ] + ); + $this->logout(); + + $response = $this->get( "/wp/v2/job-listings/{$post_id}" ); + $this->assertResponseStatus( $response, 404 ); + + $data = $response->get_data(); + $this->assertSame( 'rest_post_invalid_id', $data['code'] ?? null, 'Doubly-restricted listing must 404 with the same code core uses for missing posts.' ); + + $body = (string) wp_json_encode( $data ); + $this->assertStringNotContainsString( 'Double-restricted listing', $body, 'Title must not surface.' ); + $this->assertStringNotContainsString( 'DOUBLE-BODY', $body, 'Body content must not surface.' ); + $this->assertArrayNotHasKey( 'protected', is_array( $data ) ? $data : [], '`content.protected` envelope must not appear (would reveal listing exists).' ); + } finally { + delete_option( 'job_manager_view_job_listing_capability' ); + } + } + + /** + * @covers WP_Job_Manager_Post_Types::auth_check_can_view_job_listing + * + * Regression: an editor opening a password-protected listing in the block editor needs + * meta access (location, company name, application target, etc.) to drive Gutenberg — + * the per-meta `auth_view_callback` must mirror the editor-bypass added to + * `prepare_job_listing()`. Without this, saving the post in the editor overwrites the + * meta fields with empty values, which is data loss rather than just a display bug. + */ + public function test_rest_single_preserves_meta_for_password_protected_editor() { + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + $editor = get_user_by( 'id', $editor_id ); + foreach ( [ 'edit_job_listing', 'edit_job_listings', 'edit_others_job_listings', 'edit_published_job_listings', 'read_job_listing', 'read_private_job_listings' ] as $cap ) { + $editor->add_cap( $cap ); + } + + $post_id = $this->factory->job_listing->create( + [ + 'post_password' => 'secret', + 'post_title' => 'Editor meta preservation test', + 'meta_input' => [ + '_company_name' => 'sentinel-PWMETA-COMPANY', + '_job_location' => 'sentinel-PWMETA-LOCATION', + '_application' => 'sentinel-PWMETA-APPLY@example.com', + ], + ] + ); + + wp_set_current_user( $editor_id ); + + try { + $response = $this->get( "/wp/v2/job-listings/{$post_id}", [ 'context' => 'edit' ] ); + $this->assertResponseStatus( $response, 200 ); + + $meta = $response->get_data()['meta'] ?? []; + $this->assertSame( 'sentinel-PWMETA-COMPANY', $meta['_company_name'] ?? '', 'Editor must see _company_name meta on a password-protected listing.' ); + $this->assertSame( 'sentinel-PWMETA-LOCATION', $meta['_job_location'] ?? '', 'Editor must see _job_location meta on a password-protected listing.' ); + $this->assertSame( 'sentinel-PWMETA-APPLY@example.com', $meta['_application'] ?? '', 'Editor must see _application meta on a password-protected listing.' ); + } finally { + wp_set_current_user( 0 ); + } + } + /** * @covers WP_Job_Manager_REST_API::prepare_job_listing * From 3bb0ac45f228aafc8930a2cf5b26f643b5c157c4 Mon Sep 17 00:00:00 2001 From: Donncha O Caoimh <5656673+donnchawp@users.noreply.github.com> Date: Wed, 6 May 2026 13:00:45 +0100 Subject: [PATCH 5/5] Cover HEAD requests in the view-capability gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- includes/class-wp-job-manager-rest-api.php | 5 +++- ..._class.password-protected-listing-rest.php | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/includes/class-wp-job-manager-rest-api.php b/includes/class-wp-job-manager-rest-api.php index d69b950c6..88f47ad95 100644 --- a/includes/class-wp-job-manager-rest-api.php +++ b/includes/class-wp-job-manager-rest-api.php @@ -48,7 +48,10 @@ public static function gate_view_capability_for_single( $response, $handler, $re if ( is_wp_error( $response ) ) { return $response; } - if ( 'GET' !== $request->get_method() ) { + // HEAD falls back to the GET handler in WP_REST_Server but keeps `HEAD` as the method; + // without HEAD coverage a status-code probe (200 empty body vs 404) could distinguish + // a restricted listing from a missing one. + if ( ! in_array( $request->get_method(), [ 'GET', 'HEAD' ], true ) ) { return $response; } // Match the item route and its children (revisions, autosaves) — all of them can diff --git a/tests/php/tests/security/test_class.password-protected-listing-rest.php b/tests/php/tests/security/test_class.password-protected-listing-rest.php index 839893cee..26594510c 100644 --- a/tests/php/tests/security/test_class.password-protected-listing-rest.php +++ b/tests/php/tests/security/test_class.password-protected-listing-rest.php @@ -211,6 +211,35 @@ public function test_default_feed_query_leaves_non_job_listing_queries_alone() { $this->assertNotSame( false, $query->get( 'has_password' ) ); } + /** + * @covers WP_Job_Manager_REST_API::gate_view_capability_for_single + * + * Regression: HEAD requests fall back to the GET handler in WP_REST_Server but keep + * `HEAD` as the method, so a method check guarded only on `GET` would let HEAD through. + * `WP_REST_Posts_Controller::prepare_item_for_response()` returns an empty 200 for HEAD, + * which lets a denied client distinguish a real listing from a missing one by status + * code alone. The gate must treat HEAD identically to GET. + */ + public function test_rest_single_returns_404_for_view_cap_denied_head_request() { + update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); + + try { + $post_id = $this->factory->job_listing->create( + [ + 'post_title' => 'View-cap-restricted listing for HEAD probe', + ] + ); + $this->logout(); + + $response = $this->request( "/wp/v2/job-listings/{$post_id}", 'HEAD' ); + $this->assertResponseStatus( $response, 404 ); + $data = $response->get_data(); + $this->assertSame( 'rest_post_invalid_id', $data['code'] ?? null, 'HEAD must 404 with the same code as a missing post.' ); + } finally { + delete_option( 'job_manager_view_job_listing_capability' ); + } + } + /** * @covers WP_Job_Manager_REST_API::gate_view_capability_for_single *