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 389b51b58..88f47ad95 100644 --- a/includes/class-wp-job-manager-rest-api.php +++ b/includes/class-wp-job-manager-rest-api.php @@ -23,6 +23,61 @@ 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. + * + * 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. + * @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; + } + // 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 + // 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 ( 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,16 +138,47 @@ 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 job listings the 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'] = ''; } + 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.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..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 @@ -210,4 +210,235 @@ 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 + * + * 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 + * + * 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_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_password' => 'secret', + 'post_title' => 'Editor Edit Test', + 'post_content' => 'sentinel-PWEDIT-BODY confidential salary $250k', + 'post_excerpt' => 'sentinel-PWEDIT-EXCERPT confidential excerpt', + ] + ); + + wp_set_current_user( $editor_id ); + + 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::gate_view_capability_for_single + * + * 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_returns_404_for_view_cap_denied() { + update_option( 'job_manager_view_job_listing_capability', [ 'manage_options' ] ); + + 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' ); + } + } + + /** + * @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' ] ); + + 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' ); + } + } }