Skip to content

Harden public output paths for password-protected and capability-restricted listings#2940

Merged
donnchawp merged 6 commits intotrunkfrom
harden-listing-output-paths
May 5, 2026
Merged

Harden public output paths for password-protected and capability-restricted listings#2940
donnchawp merged 6 commits intotrunkfrom
harden-listing-output-paths

Conversation

@stephdau
Copy link
Copy Markdown
Member

@stephdau stephdau commented Apr 29, 2026

Summary

Tightens password-protected and capability-restricted enforcement across the public output paths that produce job listing data: AJAX, REST, RSS (custom and core), JSON-LD, the listing-card templates, and the Recent / Featured Jobs widgets. Previously these paths could surface listing data the visible templates already denied.

Changes

wp-job-manager-functions.php

  • get_job_listings() now adds 'has_password' => false to its default $query_args, so password-protected listings are excluded at the WP_Query layer for every consumer.
  • The listings transient cache key now folds in viewer auth state ('u' . get_current_user_id() for logged-in users, 'anon' otherwise) so cached payloads don't cross auth boundaries.
  • get_job_listings_keyword_search() no longer adds a duplicative post_password = '' SQL clause — the query-level has_password=false covers all viewers.

includes/class-wp-job-manager-cache-helper.php

  • Adds option-event hooks (add/update/delete) on job_manager_browse_job_listings_capability and job_manager_view_job_listing_capability that bump the get_job_listings transient version, so cached listings don't outlive the policy that produced them.

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

  • job_feed() (custom RSS) gates on browse capability with an empty feed, and adds has_password=false to its query.
  • New gate_feed_query_for_listings() on pre_get_posts extends the same gate to every core feed slug scoped to post_type=job_listing (?feed=rss2&post_type=job_listing, ?feed=atom&post_type=job_listing, etc.). Without this, anonymous viewers could see a password-protected listing's title, permalink, slug, and pubdate via the default RSS feed even with the browse-capability option configured.
  • get_job_listing_fields() default auth_view_callback swaps from null to a new auth_check_can_view_job_listing static method that returns false on password-protected or view-cap-denied listings; meta fields without an explicit override now flow through this gate.

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

  • prepare_job_listing() blanks top-level fields (title.rendered, link, slug, featured_media) and removes the wp:featuredmedia link relation when the viewer can't unlock the listing or is denied by view capability. The meta block is emptied in the same path.
  • exclude_filled_from_query() short-circuits the REST collection (post__in = [0]) when browse capability denies the viewer, and adds has_password=false unconditionally.

includes/class-wp-job-manager-ajax.php

  • get_listings() returns a success-shaped empty JSON payload when browse capability denies the viewer, matching what the JS client expects.

includes/widgets/class-wp-job-manager-widget-recent-jobs.php, …-widget-featured-jobs.php

  • Both widgets now early-return from widget() ahead of get_cached_widget() when job_manager_user_can_browse_job_listings() denies the viewer. Without this, the widgets bypassed the browse-capability gate the AJAX / REST / RSS paths apply, and could be served from WP_Widget's built-in cache after a policy change.

wp-job-manager-template.php

  • wpjm_output_job_listing_structured_data() now also requires job_manager_user_can_view_job_listing(); the wp_footer JSON-LD emitter honors the same view-capability check the visible template applies.

templates/content-*-job_listing.php

  • Adds a defense-in-depth password + view-capability gate at the top of each listing-card template so theme overrides can't bypass the per-post checks.

tests/php/tests/security/ (new + extended)

  • Test classes covering: keyword search + transient cache for password-protected listings, AJAX/REST/RSS/JSON-LD denial under browse / view capability configuration, REST single + collection behavior for password-protected listings (including verification that the wp:featuredmedia link relation is removed), and the new pre_get_posts feed gate (default feed excludes password-protected, browse-cap-denied feed short-circuits, non-job-listing feed queries are not affected).

Manual test results

Verified against make up / wp-env at http://localhost:8888 with subscriber registration enabled:

  • Original PoC closed: subscriber AJAX ?jm-ajax=get_listings&search_keywords=ULTRAVIOLETfound_jobs=False; all six oracle tokens (ULTRAVIOLET, InsiderCo, MAGENTA, WAREHOUSE-7, ceo-secret, tagline) return ok.
  • Custom ?feed=job_feed&search_keywords=ULTRAVIOLET → 0 items.
  • Default ?feed=rss2&post_type=job_listing → public listings present, password-protected listings absent.
  • REST single (/wp/v2/job-listings/<protected_id>?_embed=1) returns blanked title/link/slug/featured_media/meta and no wp:featuredmedia link relation.
  • REST collection excludes password-protected, includes public.
  • With job_manager_browse_job_listings_capability=["read"]: anonymous AJAX, REST collection, custom job_feed, default rss2&post_type=job_listing, and Recent Jobs widget all return empty / no output. Subscriber Alice (has read) still sees listings.
  • Cache amplification fixed: subscriber priming the cache for a keyword does not surface password-protected listings on a subsequent anonymous request (separate transient slots verified in wp_options).

Known follow-ups

Release Notes

  • Fixes a series of information disclosure issues affecting password-protected and capability-restricted job listings.

Test plan

  • make lint — clean
  • make test — 463 tests, 1891 assertions, 3 skipped (geocode, same as baseline)
  • Manual: REST single endpoint for a password-protected listing returns blanked title/link/slug/featured_media and empty meta; ?_embed=1 does not surface the attachment.
  • Manual: with browse capability set, AJAX get_listings, REST collection, RSS job_feed, default ?feed=rss2&post_type=job_listing, and Recent Jobs widget return empty payloads to denied viewers; JSON-LD does not emit on the single-listing footer.
  • Manual: with no capability config (default), public listings still surface to anonymous viewers in AJAX / REST / RSS (custom and default) / JSON-LD / widgets, and the keyword search returns public listings.
  • Manual: cached listings populated by an authenticated user do not surface to a subsequent anonymous request on the same query.

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

`get_job_listings()` now excludes password-protected posts at the query level and segregates the listings transient cache by viewer auth state. The keyword-search filter no longer adds duplicative `post_password = ''` SQL — the query-level `has_password=false` covers all viewers. The capability-option event hooks bump the listings transient version so cached results don't outlive the policy that produced them. RSS `job_feed()` query also excludes password-protected listings.
REST single responses for password-protected or capability-denied listings now blank `title.rendered`, drop `link`, blank `slug`, zero `featured_media`, remove the `wp:featuredmedia` link relation, and empty `meta`. Meta auth_view_callback now defaults to a job-listing-aware check so individual fields without an explicit override gate on password / view-capability. REST collection short-circuits when the viewer lacks the browse capability, and excludes password-protected listings.
…sword gating

AJAX `get_listings`, the RSS `job_feed`, the JSON-LD emitter, and the three rendering templates (`content-*-job_listing.php`) now apply the same browse / view capability and password gates the visible `[jobs]` shortcode and single-listing template apply. Theme overrides of the listing templates inherit the per-post gate as defense in depth.
donnchawp added 3 commits May 5, 2026 14:15
Both widgets called `get_job_listings()` and rendered `content-widget-job_listing.php` without honoring `job_manager_browse_job_listings_capability`, leaving a public listing-browse path open after the AJAX / REST / RSS hardening. Add an early-return to `widget()` ahead of `get_cached_widget()` so a browse-denied viewer is never served from cache or rendered fresh.
The custom `?feed=job_feed` endpoint was hardened to honor browse capability and exclude password-protected listings, but the default core feeds scoped to the post type (`?feed=rss2&post_type=job_listing`, `?feed=atom&post_type=job_listing`, etc.) bypassed both gates. Anonymous viewers could see a password-protected listing's title (with WP's "Protected:" prefix), permalink, slug, and pubdate via the default RSS feed, even with `job_manager_browse_job_listings_capability` configured.

Add a `pre_get_posts` gate that, on any feed main-query targeting `job_listing`, short-circuits to an empty result for browse-cap-denied viewers and otherwise sets `has_password=false`. The custom `job_feed` already sets these explicitly, so the new hook is a no-op there.
The Recent / Featured Jobs widgets cache rendered HTML via WP_Widget's built-in cache, keyed only by widget instance id. When `job_manager_view_job_listing_capability` is configured, the per-listing template gate in `content-widget-job_listing.php` makes output viewer-dependent: a viewer with the required capability primes the cache with their listing cards, then a viewer without it receives those cards from the shared cache and bypasses the per-listing check.

Skip both `get_cached_widget()` and `cache_widget()` whenever the view-capability option is set. The default case (no view-cap) keeps the shared cache and its perf benefit; the restricted case renders fresh per request, accepting the perf cost for correctness.

A per-user partition was considered and rejected — widgets render on every page view, so per-user keys would balloon the cache with low hit rates per entry.
@donnchawp donnchawp merged commit 67240a2 into trunk May 5, 2026
35 checks passed
@donnchawp donnchawp deleted the harden-listing-output-paths branch May 5, 2026 15:13
masteradhoc pushed a commit to masteradhoc/WP-Job-Manager that referenced this pull request May 7, 2026
Closes Automattic#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 Automattic#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.
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.

2 participants