Harden public output paths for password-protected and capability-restricted listings#2940
Merged
Harden public output paths for password-protected and capability-restricted listings#2940
Conversation
`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.
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.
8 tasks
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.
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
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.phpget_job_listings()now adds'has_password' => falseto its default$query_args, so password-protected listings are excluded at the WP_Query layer for every consumer.'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 duplicativepost_password = ''SQL clause — the query-levelhas_password=falsecovers all viewers.includes/class-wp-job-manager-cache-helper.phpjob_manager_browse_job_listings_capabilityandjob_manager_view_job_listing_capabilitythat bump theget_job_listingstransient version, so cached listings don't outlive the policy that produced them.includes/class-wp-job-manager-post-types.phpjob_feed()(custom RSS) gates on browse capability with an empty feed, and addshas_password=falseto its query.gate_feed_query_for_listings()onpre_get_postsextends the same gate to every core feed slug scoped topost_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()defaultauth_view_callbackswaps fromnullto a newauth_check_can_view_job_listingstatic 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.phpprepare_job_listing()blanks top-level fields (title.rendered,link,slug,featured_media) and removes thewp:featuredmedialink relation when the viewer can't unlock the listing or is denied by view capability. Themetablock 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 addshas_password=falseunconditionally.includes/class-wp-job-manager-ajax.phpget_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.phpwidget()ahead ofget_cached_widget()whenjob_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 fromWP_Widget's built-in cache after a policy change.wp-job-manager-template.phpwpjm_output_job_listing_structured_data()now also requiresjob_manager_user_can_view_job_listing(); thewp_footerJSON-LD emitter honors the same view-capability check the visible template applies.templates/content-*-job_listing.phptests/php/tests/security/(new + extended)wp:featuredmedialink relation is removed), and the newpre_get_postsfeed 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 athttp://localhost:8888with subscriber registration enabled:?jm-ajax=get_listings&search_keywords=ULTRAVIOLET→found_jobs=False; all six oracle tokens (ULTRAVIOLET,InsiderCo,MAGENTA,WAREHOUSE-7,ceo-secret,tagline) returnok.?feed=job_feed&search_keywords=ULTRAVIOLET→ 0 items.?feed=rss2&post_type=job_listing→ public listings present, password-protected listings absent./wp/v2/job-listings/<protected_id>?_embed=1) returns blanked title/link/slug/featured_media/meta and nowp:featuredmedialink relation.job_manager_browse_job_listings_capability=["read"]: anonymous AJAX, REST collection, customjob_feed, defaultrss2&post_type=job_listing, and Recent Jobs widget all return empty / no output. Subscriber Alice (hasread) still sees listings.wp_options).Known follow-ups
content.rendered/excerpt.renderedare not blanked for view-capability-denied (non-password-protected) listings. Pre-existing on trunk; this PR closes most of the gap but leaves the body fields for a follow-up.Release Notes
Test plan
make lint— cleanmake test— 463 tests, 1891 assertions, 3 skipped (geocode, same as baseline)?_embed=1does not surface the attachment.get_listings, REST collection, RSSjob_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.