Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions includes/class-wp-job-manager-post-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down
100 changes: 93 additions & 7 deletions includes/class-wp-job-manager-rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<id>\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 ]
);
}

/**
Expand Down Expand Up @@ -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'] );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading
Loading