From 7fd425c8d39a026ae2be5af3a03746768601dba9 Mon Sep 17 00:00:00 2001 From: Ajay D'Souza Date: Mon, 12 Oct 2026 08:05:28 +0000 Subject: [PATCH 1/4] Withhold password-protected article content from the REST API The /wzkb/v1/knowledgebase, /wzkb/v1/knowledgebase/{id} and /wzkb/v1/search endpoints returned the content and excerpt of password-protected articles without the password. Return an empty excerpt and content when post_password_required() is true, add a 'protected' flag like core, and skip caching responses that include password-protected posts so an unlocked response is never served to another requester. --- includes/rest/class-rest-controller.php | 59 ++++++++++++++++++++----- readme.txt | 12 ++++- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/includes/rest/class-rest-controller.php b/includes/rest/class-rest-controller.php index 4cef4f1..8501589 100644 --- a/includes/rest/class-rest-controller.php +++ b/includes/rest/class-rest-controller.php @@ -520,14 +520,16 @@ public function get_knowledgebase_posts( WP_REST_Request $request ) { $response->header( 'X-WP-Total', (string) (int) $query->found_posts ); $response->header( 'X-WP-TotalPages', (string) (int) $query->max_num_pages ); - $this->cache_set( - $cache_key, - array( - 'body' => $posts, - 'total' => (int) $query->found_posts, - 'pages' => (int) $query->max_num_pages, - ) - ); + if ( ! $this->has_password_protected_post( $query->posts ) ) { + $this->cache_set( + $cache_key, + array( + 'body' => $posts, + 'total' => (int) $query->found_posts, + 'pages' => (int) $query->max_num_pages, + ) + ); + } return $response; } @@ -558,6 +560,11 @@ public function get_single_knowledgebase_post( WP_REST_Request $request ) { ); } + // Password-protected responses depend on the requester's password cookie, so never cache them. + if ( '' !== $post->post_password ) { + return rest_ensure_response( $this->prepare_post_for_response( $post, true ) ); + } + $cache_key = $this->get_cache_key( 'kb_single', array( @@ -742,7 +749,9 @@ public function get_search_results( WP_REST_Request $request ) { $results[] = $this->prepare_post_for_response( $post ); } - $this->cache_set( $cache_key, $results ); + if ( ! $this->has_password_protected_post( $wp_query->posts ) ) { + $this->cache_set( $cache_key, $results ); + } return rest_ensure_response( $results ); } @@ -1013,6 +1022,9 @@ private function get_post_list_args() { /** * Prepare KB post data. * + * Password-protected posts return an empty excerpt and content unless the + * requester has entered the post password, matching core's REST controller. + * * @param \WP_Post $post Post object. * @param bool $include_content Include full content flag. * @return array @@ -1023,25 +1035,50 @@ private function prepare_post_for_response( $post, $include_content = false ) { return array(); } + $is_protected = '' !== $post->post_password; + $is_locked = $is_protected && post_password_required( $post ); + $data = array( 'id' => (int) $post->ID, 'title' => get_the_title( $post ), 'slug' => $post->post_name, - 'excerpt' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 55 ), + 'excerpt' => $is_locked ? '' : wp_trim_words( wp_strip_all_tags( $post->post_content ), 55 ), 'permalink' => get_permalink( $post ), 'products' => $this->format_terms( get_the_terms( $post, 'wzkb_product' ) ), 'sections' => $this->format_terms( get_the_terms( $post, 'wzkb_category' ) ), 'date' => mysql2date( DATE_RFC3339, $post->post_date_gmt, false ), 'modified' => mysql2date( DATE_RFC3339, $post->post_modified_gmt, false ), + 'protected' => $is_protected, ); if ( $include_content ) { - $data['content'] = apply_filters( 'the_content', $post->post_content ); + $data['content'] = $is_locked ? '' : apply_filters( 'the_content', $post->post_content ); } return $data; } + /** + * Check whether any post in a list is password protected. + * + * Responses containing such posts vary with the requester's password cookie + * and must not be cached. + * + * @since 3.2.1 + * + * @param array $posts Post objects. + * @return bool + */ + private function has_password_protected_post( array $posts ): bool { + foreach ( $posts as $post ) { + if ( $post instanceof \WP_Post && '' !== $post->post_password ) { + return true; + } + } + + return false; + } + /** * Format terms list. * diff --git a/readme.txt b/readme.txt index 3e0c2d3..bf592f9 100644 --- a/readme.txt +++ b/readme.txt @@ -195,6 +195,15 @@ Release date: TBD * [Pro] Reused cached Ask the Docs answers for reworded questions until their source articles change. +**Security** + +* The `/wzkb/v1/knowledgebase` and `/wzkb/v1/search` REST endpoints returned the content and excerpt of password-protected articles without the password. + +**Fixed** + +* [Pro] GitHub imports and reimports stripped image captions from articles. +* [Pro] Ask the Docs could cite another company's article. + = 3.2.0 = Release date: 7 October 2026 @@ -219,6 +228,7 @@ Release post: https://webberzone.com/announcements/knowledge-base-v3-2/ * The Knowledge Base search results page size overrode the REST search endpoint's `limit` parameter. * Knowledge Base search forms showed duplicate suggestions when Better Search live search was also active. +* [Pro] Help widget searches returned no articles with Better Search active, and the search box lost focus while typing. * Text in the Knowledge Base Alerts block could not be selected reliably in the editor. * Knowledge Base Alerts blocks without an alignment saved an invalid `text-align: none` inline style. * Settings repeater button labels used the wrong translation domain. @@ -230,4 +240,4 @@ For the changelog of earlier versions, please refer to the [releases page on Git == Upgrade Notice == = 3.2.1 = -Faster Pro Ask the Docs answers with a choice of model and a fallback provider, and better answer caching. Pro GitHub imports preserve image captions. +Security release. Stops the REST API exposing password-protected articles. Also adds faster Pro Ask the Docs answers with a choice of model and a fallback provider, and better answer caching. Pro GitHub imports preserve image captions. From eb8cdbad781790b2911fdeb5275f835b03d99510 Mon Sep 17 00:00:00 2001 From: Ajay D'Souza Date: Mon, 12 Oct 2026 09:52:21 +0000 Subject: [PATCH 2/4] Hide password-protected article content from TOCs, thumbnails and the Help widget The table of contents renderers that sit outside the article body (the [kbtoc] shortcode, the floating TOC, the docs layout outline, and the TOC widget and block) built their outline from the raw content of a password-protected article before the password was entered. Add TOC::is_post_locked() and return nothing from each renderer when the post is locked, while keeping the TOC in the editor preview for users who can edit the post. Also blank the Help widget's manual excerpt for locked suggested articles, skip the thumbnail content scan for locked posts, and version the REST cache keys so responses cached by 3.2.0 are never served again. --- includes/frontend/class-media-handler.php | 5 +++-- includes/frontend/class-shortcodes.php | 2 +- includes/frontend/class-toc.php | 22 ++++++++++++++++++++++ includes/rest/class-rest-controller.php | 13 ++++++++++++- readme.txt | 6 +++++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/includes/frontend/class-media-handler.php b/includes/frontend/class-media-handler.php index 416621e..7ca86e1 100644 --- a/includes/frontend/class-media-handler.php +++ b/includes/frontend/class-media-handler.php @@ -600,8 +600,9 @@ protected static function get_thumbnail_from_content_scan( \WP_Post $post, array ); } - // Skip content scanning for very large posts to prevent memory exhaustion. - if ( strlen( $post->post_content ) > 50000 ) { // 50KB limit. + // Skip content scanning for very large posts to prevent memory exhaustion, and for + // password-protected posts so their images are not exposed before the password is entered. + if ( strlen( $post->post_content ) > 50000 || post_password_required( $post ) ) { // 50KB limit. $post_content = ''; } else { /** diff --git a/includes/frontend/class-shortcodes.php b/includes/frontend/class-shortcodes.php index 8ed1440..9f29d61 100644 --- a/includes/frontend/class-shortcodes.php +++ b/includes/frontend/class-shortcodes.php @@ -266,7 +266,7 @@ public static function toc( $atts ): string { ); $post = get_post(); - if ( ! $post ) { + if ( ! $post || TOC::is_post_locked( $post ) ) { return ''; } diff --git a/includes/frontend/class-toc.php b/includes/frontend/class-toc.php index 4beb106..9872fb2 100644 --- a/includes/frontend/class-toc.php +++ b/includes/frontend/class-toc.php @@ -49,6 +49,28 @@ public function __construct() { } } + /** + * Check whether a post's content is locked behind a password for the current visitor. + * + * TOC renderers outside the article body use this so they do not expose the headings + * of a password-protected article before the password is entered. Editors previewing + * the post in the block editor still see the TOC. + * + * @since 3.2.1 + * + * @param \WP_Post $post Post object. + * @return bool True when the TOC must not be shown. + */ + public static function is_post_locked( \WP_Post $post ): bool { + if ( ! post_password_required( $post ) ) { + return false; + } + + $is_editor_preview = ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) && current_user_can( 'edit_post', $post->ID ); + + return ! $is_editor_preview; + } + /** * Filter callback: add anchor IDs to headings in article content. * diff --git a/includes/rest/class-rest-controller.php b/includes/rest/class-rest-controller.php index 8501589..de91ca6 100644 --- a/includes/rest/class-rest-controller.php +++ b/includes/rest/class-rest-controller.php @@ -41,6 +41,16 @@ class REST_Controller { */ private const DEFAULT_TTL = 300; + /** + * Cache key format version. + * + * Changing it orphans entries cached by earlier releases, such as 3.2.0 responses + * that included password-protected content. + * + * @since 3.2.1 + */ + private const CACHE_KEY_VERSION = 'p2'; + /** * Constructor. */ @@ -1137,8 +1147,9 @@ private function get_cache_key( $prefix, $context = array() ) { } return sprintf( - '%s_%d_%s', + '%s_%s_%d_%s', $prefix, + self::CACHE_KEY_VERSION, $this->get_cache_version(), empty( $context ) ? 'static' : md5( wp_json_encode( $context ) ) ); diff --git a/readme.txt b/readme.txt index bf592f9..2fa0cc2 100644 --- a/readme.txt +++ b/readme.txt @@ -198,6 +198,10 @@ Release date: TBD **Security** * The `/wzkb/v1/knowledgebase` and `/wzkb/v1/search` REST endpoints returned the content and excerpt of password-protected articles without the password. +* The `[kbtoc]` shortcode showed the headings of password-protected articles before the password was entered. +* Thumbnails could use an image from inside a password-protected article. +* [Pro] The floating table of contents, the documentation layout's "On this page" outline, and the Table of Contents widget and block showed the headings of password-protected articles before the password was entered. +* [Pro] Help widget suggested articles showed the manual excerpt of password-protected articles. **Fixed** @@ -240,4 +244,4 @@ For the changelog of earlier versions, please refer to the [releases page on Git == Upgrade Notice == = 3.2.1 = -Security release. Stops the REST API exposing password-protected articles. Also adds faster Pro Ask the Docs answers with a choice of model and a fallback provider, and better answer caching. Pro GitHub imports preserve image captions. +Security release. Stops password-protected articles leaking through the REST API, tables of contents and thumbnails. Also adds faster Pro Ask the Docs answers with a choice of model and a fallback provider, and better answer caching. Pro GitHub imports preserve image captions. From 73f8db1444564c35ef14cdeeadde87c5561b6374 Mon Sep 17 00:00:00 2001 From: Ajay D'Souza Date: Mon, 12 Oct 2026 22:43:00 +0400 Subject: [PATCH 3/4] Close remaining password-protected article disclosure paths --- includes/frontend/class-display.php | 16 +- includes/frontend/class-media-handler.php | 5 +- includes/frontend/class-related.php | 3 +- includes/frontend/class-toc.php | 4 +- includes/rest/class-rest-controller.php | 26 +-- .../tests/test-rest-password-protection.php | 199 ++++++++++++++++++ 6 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 phpunit/tests/test-rest-password-protection.php diff --git a/includes/frontend/class-display.php b/includes/frontend/class-display.php index 8f9c4cd..e968c4a 100644 --- a/includes/frontend/class-display.php +++ b/includes/frontend/class-display.php @@ -565,6 +565,7 @@ public static function get_term_posts( $term ): \WP_Query { // Get all the posts for the current term excluding posts located in its child terms. $args = array( + 'post_status' => 'publish', 'posts_per_page' => -1, 'no_found_rows' => true, 'ignore_sticky_posts' => true, @@ -587,11 +588,15 @@ public static function get_term_posts( $term ): \WP_Query { $query = null; $cache_enabled = ! empty( self::get_cached_option( 'cache' ) ); $meta_key = null; + $last_changed = wp_cache_get_last_changed( 'posts' ); // Support caching to speed up retrieval. Cache::get_key() includes the current language. if ( $cache_enabled ) { $meta_key = Cache::get_key( $args ); - $query = Cache::get( $term->term_id, $meta_key ); + $cached = Cache::get( $term->term_id, $meta_key ); + if ( is_array( $cached ) && ( $cached['last_changed'] ?? '' ) === $last_changed ) { + $query = $cached['query'] ?? null; + } } if ( empty( $query ) ) { @@ -599,7 +604,14 @@ public static function get_term_posts( $term ): \WP_Query { // Support caching to speed up retrieval. if ( $cache_enabled ) { - Cache::set( $term->term_id, $meta_key, $query ); + Cache::set( + $term->term_id, + $meta_key, + array( + 'last_changed' => $last_changed, + 'query' => $query, + ) + ); } } diff --git a/includes/frontend/class-media-handler.php b/includes/frontend/class-media-handler.php index 7ca86e1..bbbd4bf 100644 --- a/includes/frontend/class-media-handler.php +++ b/includes/frontend/class-media-handler.php @@ -600,8 +600,7 @@ protected static function get_thumbnail_from_content_scan( \WP_Post $post, array ); } - // Skip content scanning for very large posts to prevent memory exhaustion, and for - // password-protected posts so their images are not exposed before the password is entered. + // Skip oversized or locked content before scanning for images. if ( strlen( $post->post_content ) > 50000 || post_password_required( $post ) ) { // 50KB limit. $post_content = ''; } else { @@ -661,7 +660,7 @@ protected static function get_thumbnail_from_content_scan( \WP_Post $post, array */ protected static function get_thumbnail_from_first_child( \WP_Post $post, array $args ): array { $dimensions = self::get_thumb_size( $args['size'] ); - $postimage = self::get_first_image( $post->ID, $dimensions[0], $dimensions[1] ); + $postimage = post_password_required( $post ) ? '' : self::get_first_image( $post->ID, $dimensions[0], $dimensions[1] ); if ( ! $postimage ) { return array( diff --git a/includes/frontend/class-related.php b/includes/frontend/class-related.php index cf40afd..f8d4a91 100644 --- a/includes/frontend/class-related.php +++ b/includes/frontend/class-related.php @@ -112,6 +112,7 @@ public static function get_related_articles_query( $args = array() ) { 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', 'posts_per_page' => $args['numberposts'], 'post__not_in' => $exclude, 'orderby' => 'date', @@ -299,7 +300,7 @@ public static function get_related_articles( $args = array() ) { $output .= ''; - if ( $args['show_excerpt'] ) { + if ( $args['show_excerpt'] && ! post_password_required( $post_id ) ) { $excerpt = get_the_excerpt(); if ( empty( $excerpt ) ) { $content = get_post_field( 'post_content', $post_id ); diff --git a/includes/frontend/class-toc.php b/includes/frontend/class-toc.php index 9872fb2..b3cc4c7 100644 --- a/includes/frontend/class-toc.php +++ b/includes/frontend/class-toc.php @@ -52,9 +52,7 @@ public function __construct() { /** * Check whether a post's content is locked behind a password for the current visitor. * - * TOC renderers outside the article body use this so they do not expose the headings - * of a password-protected article before the password is entered. Editors previewing - * the post in the block editor still see the TOC. + * Editors previewing the post in the block editor still see the TOC. * * @since 3.2.1 * diff --git a/includes/rest/class-rest-controller.php b/includes/rest/class-rest-controller.php index de91ca6..0b32475 100644 --- a/includes/rest/class-rest-controller.php +++ b/includes/rest/class-rest-controller.php @@ -49,7 +49,7 @@ class REST_Controller { * * @since 3.2.1 */ - private const CACHE_KEY_VERSION = 'p2'; + private const CACHE_KEY_VERSION = 'p3'; /** * Constructor. @@ -470,6 +470,7 @@ public function get_knowledgebase_posts( WP_REST_Request $request ) { $query_args = array( 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', 'ignore_sticky_posts' => true, 'no_found_rows' => false, 'posts_per_page' => $per_page, @@ -660,29 +661,14 @@ public function get_related_articles( WP_REST_Request $request ) { ); } - $cache_key = $this->get_cache_key( - 'related', - array( - 'post_id' => $post_id, - 'limit' => $limit, - ) - ); - - $cached = $this->cache_get( $cache_key ); - if ( false !== $cached ) { - return rest_ensure_response( $cached ); - } - + // Render per visitor because protected thumbnails vary with the password cookie. $related = Related::get_related_articles( array( - 'post_id' => $post_id, - 'limit' => $limit, - 'echo' => false, + 'post' => $post, + 'numberposts' => $limit, ) ); - $this->cache_set( $cache_key, $related ); - return rest_ensure_response( $related ); } @@ -1033,7 +1019,7 @@ private function get_post_list_args() { * Prepare KB post data. * * Password-protected posts return an empty excerpt and content unless the - * requester has entered the post password, matching core's REST controller. + * requester has entered the post password using WordPress's password form. * * @param \WP_Post $post Post object. * @param bool $include_content Include full content flag. diff --git a/phpunit/tests/test-rest-password-protection.php b/phpunit/tests/test-rest-password-protection.php new file mode 100644 index 0000000..a304d39 --- /dev/null +++ b/phpunit/tests/test-rest-password-protection.php @@ -0,0 +1,199 @@ +post->create( + array( + 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', + 'post_title' => 'Password regression article', + 'post_content' => 'wzkbpasswordmarker confidential body', + 'post_password' => 'LocalSecret!2026', + ) + ); + wp_set_current_user( 0 ); + $cookie_name = 'wp-postpass_' . COOKIEHASH; + $old_cookie = $_COOKIE[ $cookie_name ] ?? null; + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + $list->set_param( 'per_page', 50 ); + $search = new WP_REST_Request( 'GET', '/wzkb/v1/search' ); + $search->set_param( 'query', 'regression' ); + + try { + unset( $_COOKIE[ $cookie_name ] ); + $locked = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $locked->get_status() ); + $this->assertSame( '', $locked->get_data()['content'] ); + $this->assertSame( '', $locked->get_data()['excerpt'] ); + $this->assertTrue( $locked->get_data()['protected'] ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + require_once ABSPATH . WPINC . '/class-phpass.php'; + $hasher = new PasswordHash( 8, true ); + $_COOKIE[ $cookie_name ] = $hasher->HashPassword( 'LocalSecret!2026' ); + $unlocked = rest_get_server()->dispatch( $request ); + $this->assertStringContainsString( 'wzkbpasswordmarker', $unlocked->get_data()['content'] ); + foreach ( array( $list, $search ) as $collection ) { + $response = rest_get_server()->dispatch( $collection ); + $items = array_column( $response->get_data(), null, 'id' ); + $this->assertStringContainsString( 'wzkbpasswordmarker', $items[ $post_id ]['excerpt'] ); + } + + unset( $_COOKIE[ $cookie_name ] ); + $this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] ); + foreach ( array( $list, $search ) as $collection ) { + $response = rest_get_server()->dispatch( $collection ); + $items = array_column( $response->get_data(), null, 'id' ); + $this->assertSame( '', $items[ $post_id ]['excerpt'] ); + } + } finally { + if ( null === $old_cookie ) { + unset( $_COOKIE[ $cookie_name ] ); + } else { + $_COOKIE[ $cookie_name ] = $old_cookie; + } + } + } + + public function test_adding_a_password_invalidates_open_responses() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', + 'post_title' => 'Password transition article', + 'post_content' => 'wzkbtransitionmarker confidential body', + ) + ); + wp_set_current_user( 0 ); + $single = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + $list->set_param( 'search', 'transition' ); + $this->assertStringContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $single )->get_data() ) ); + $this->assertStringContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $list )->get_data() ) ); + + wp_update_post( array( 'ID' => $post_id, 'post_password' => 'new-password' ) ); + + $this->assertSame( '', rest_get_server()->dispatch( $single )->get_data()['content'] ); + $this->assertStringNotContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $list )->get_data() ) ); + } + public function test_public_collection_does_not_cache_private_articles() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'private', 'post_content' => 'wzkbprivatemarker' ) ); + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + rest_get_server()->dispatch( $request ); + wp_set_current_user( 0 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertNotContains( $post_id, array_column( $response->get_data(), 'id' ) ); + } + + public function test_related_rest_does_not_cache_unlocked_body_images() { + $source = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish' ) ); + $protected = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_password' => 'secret', 'post_content' => '' ) ); + $request = new WP_REST_Request( 'GET', '/wzkb/v1/related' ); + $request->set_param( 'post_id', $source ); + $request->set_param( 'limit', 5 ); + wp_set_current_user( 0 ); + require_once ABSPATH . WPINC . '/class-phpass.php'; + $hasher = new PasswordHash( 8, true ); + $cookie_name = 'wp-postpass_' . COOKIEHASH; + $old_cookie = $_COOKIE[ $cookie_name ] ?? null; + try { + $_COOKIE[ $cookie_name ] = $hasher->HashPassword( 'secret' ); + $unlocked = rest_get_server()->dispatch( $request ); + $this->assertStringContainsString( 'wzkbprotectedimage', $unlocked->get_data() ); + unset( $_COOKIE[ $cookie_name ] ); + $this->assertStringNotContainsString( 'wzkbprotectedimage', rest_get_server()->dispatch( $request )->get_data() ); + } finally { + if ( null === $old_cookie ) { + unset( $_COOKIE[ $cookie_name ] ); + } else { + $_COOKIE[ $cookie_name ] = $old_cookie; + } + } + } + + public function test_route_filters_and_taxonomy_parameters_cannot_unlock_content() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_content' => 'wzkbparammarker', 'post_password' => 'secret' ) ); + $section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) ); + $product = self::factory()->term->create( array( 'taxonomy' => 'wzkb_product' ) ); + wp_set_object_terms( $post_id, array( $section ), 'wzkb_category' ); + wp_set_object_terms( $post_id, array( $product ), 'wzkb_product' ); + add_filter( 'wzkb_rest_route_permission', '__return_true' ); + foreach ( array( 0, self::factory()->user->create( array( 'role' => 'subscriber' ) ), self::factory()->user->create( array( 'role' => 'contributor' ) ) ) as $user_id ) { + wp_set_current_user( $user_id ); + foreach ( array( '', 'en', 'fr' ) as $language ) { + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $request->set_param( 'lang', $language ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'password', 'secret' ); + $this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] ); + $list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + $list->set_param( 'product', $product ); + $list->set_param( 'section', $section ); + $list->set_param( 'per_page', 1 ); + $list->set_param( 'page', 1 ); + $list->set_param( 'lang', $language ); + $response = rest_get_server()->dispatch( $list ); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( '', $response->get_data()[0]['excerpt'] ); + $this->assertSame( '1', $response->get_headers()['X-WP-Total'] ); + } + } + } + + public function test_password_removal_restores_content() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_content' => 'wzkbremovedpassword', 'post_password' => 'secret' ) ); + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] ); + wp_update_post( array( 'ID' => $post_id, 'post_password' => '' ) ); + $this->assertStringContainsString( 'wzkbremovedpassword', rest_get_server()->dispatch( $request )->get_data()['content'] ); + } + + public function test_attached_image_fallback_does_not_expose_locked_images() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_password' => 'secret' ) ); + $attachment = self::factory()->post->create( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_parent' => $post_id, 'post_mime_type' => 'image/jpeg' ) ); + update_post_meta( $attachment, '_wp_attached_file', 'wzkbsecretattachment.jpg' ); + wp_set_current_user( 0 ); + $html = \WebberZone\Knowledge_Base\Frontend\Media_Handler::get_the_post_thumbnail( array( 'post' => $post_id ) ); + $this->assertStringNotContainsString( 'wzkbsecretattachment', $html ); + wp_update_post( array( 'ID' => $post_id, 'post_password' => '' ) ); + $html = \WebberZone\Knowledge_Base\Frontend\Media_Handler::get_the_post_thumbnail( array( 'post' => $post_id ) ); + $this->assertStringContainsString( 'wzkbsecretattachment', $html ); + } + + public function test_section_query_refreshes_when_an_article_gains_a_password() { + $section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) ); + $id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_excerpt' => 'wzkbstalesectionmarker' ) ); + wp_set_object_terms( $id, array( $section ), 'wzkb_category' ); + wp_set_current_user( 0 ); + $property = new ReflectionProperty( \WebberZone\Knowledge_Base\Frontend\Display::class, 'options_cache' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $previous = $property->getValue(); + $property->setValue( null, array( 'cache' => true ) ); + try { + $term = get_term( $section ); + $query = \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $this->assertSame( '', $query->posts[0]->post_password ); + wp_update_post( array( 'ID' => $id, 'post_password' => 'secret' ) ); + $query = \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $this->assertSame( 'secret', $query->posts[0]->post_password ); + $this->assertStringNotContainsString( 'wzkbstalesectionmarker', get_the_excerpt( $query->posts[0] ) ); + } finally { + $property->setValue( null, $previous ); + } + } + +} From 70c5075dc3fb5266eb6a356c86943c12846c2000 Mon Sep 17 00:00:00 2001 From: Ajay D'Souza Date: Mon, 12 Oct 2026 18:54:06 +0000 Subject: [PATCH 4/4] Sync the section cache fix and 3.2.1 changelog from Pro Key the section listing cache to the persistent content version in Util\Cache instead of wp_cache_get_last_changed( 'posts' ), which changes on every request without a persistent object cache. Add the remaining password-protection fixes to the changelog, without Pro-only fixes. --- includes/frontend/class-display.php | 8 ++--- includes/rest/class-rest-controller.php | 6 ++-- includes/util/class-cache.php | 24 +++++++++++++++ .../tests/test-rest-password-protection.php | 30 +++++++++++++++++++ readme.txt | 11 ++++--- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/includes/frontend/class-display.php b/includes/frontend/class-display.php index e968c4a..454f966 100644 --- a/includes/frontend/class-display.php +++ b/includes/frontend/class-display.php @@ -588,13 +588,13 @@ public static function get_term_posts( $term ): \WP_Query { $query = null; $cache_enabled = ! empty( self::get_cached_option( 'cache' ) ); $meta_key = null; - $last_changed = wp_cache_get_last_changed( 'posts' ); + $version = Cache::get_content_version(); // Support caching to speed up retrieval. Cache::get_key() includes the current language. if ( $cache_enabled ) { $meta_key = Cache::get_key( $args ); $cached = Cache::get( $term->term_id, $meta_key ); - if ( is_array( $cached ) && ( $cached['last_changed'] ?? '' ) === $last_changed ) { + if ( is_array( $cached ) && ( $cached['version'] ?? 0 ) === $version ) { $query = $cached['query'] ?? null; } } @@ -608,8 +608,8 @@ public static function get_term_posts( $term ): \WP_Query { $term->term_id, $meta_key, array( - 'last_changed' => $last_changed, - 'query' => $query, + 'version' => $version, + 'query' => $query, ) ); } diff --git a/includes/rest/class-rest-controller.php b/includes/rest/class-rest-controller.php index 0b32475..31f5ba5 100644 --- a/includes/rest/class-rest-controller.php +++ b/includes/rest/class-rest-controller.php @@ -13,6 +13,7 @@ use WP_REST_Response; use WP_REST_Server; use WebberZone\Knowledge_Base\Frontend\Related; +use WebberZone\Knowledge_Base\Util\Cache; use WebberZone\Knowledge_Base\Util\Hook_Registry; if ( ! defined( 'WPINC' ) ) { @@ -1147,7 +1148,7 @@ private function get_cache_key( $prefix, $context = array() ) { * @return int */ private function get_cache_version() { - return (int) get_option( 'wzkb_rest_cache_version', 1 ); + return Cache::get_content_version(); } /** @@ -1156,8 +1157,7 @@ private function get_cache_version() { * @return void */ public function bump_cache_version() { - $version = $this->get_cache_version(); - update_option( 'wzkb_rest_cache_version', $version + 1, false ); + Cache::bump_content_version(); } /** diff --git a/includes/util/class-cache.php b/includes/util/class-cache.php index 91523fb..3b54db5 100644 --- a/includes/util/class-cache.php +++ b/includes/util/class-cache.php @@ -139,6 +139,30 @@ public static function get_key( $attr ) { return $meta_key; } + /** + * Get the content version used to invalidate cached Knowledge Base output. + * + * Stored as an option so it persists without an object cache. It is bumped + * whenever an article is saved, changes status or is deleted, and when + * sections or products change. + * + * @since 3.2.1 + * + * @return int Content version. + */ + public static function get_content_version(): int { + return (int) get_option( 'wzkb_rest_cache_version', 1 ); + } + + /** + * Increment the content version, invalidating cached Knowledge Base output. + * + * @since 3.2.1 + */ + public static function bump_content_version(): void { + update_option( 'wzkb_rest_cache_version', self::get_content_version() + 1, false ); + } + /** * Get the timestamp meta key for a cache entry. * diff --git a/phpunit/tests/test-rest-password-protection.php b/phpunit/tests/test-rest-password-protection.php index a304d39..bd77663 100644 --- a/phpunit/tests/test-rest-password-protection.php +++ b/phpunit/tests/test-rest-password-protection.php @@ -196,4 +196,34 @@ public function test_section_query_refreshes_when_an_article_gains_a_password() } } + public function test_section_query_cache_survives_a_new_request_without_an_object_cache() { + global $wpdb; + + $section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) ); + $id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish' ) ); + wp_set_object_terms( $id, array( $section ), 'wzkb_category' ); + $property = new ReflectionProperty( \WebberZone\Knowledge_Base\Frontend\Display::class, 'options_cache' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $previous = $property->getValue(); + $property->setValue( null, array( 'cache' => true ) ); + $meta_ids = static function () use ( $wpdb, $section ) { + return $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->termmeta} WHERE term_id = %d AND meta_key LIKE %s ORDER BY meta_id", $section, '\_wzkb\_cache\_%' ) ); + }; + try { + $term = get_term( $section ); + \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $first = $meta_ids(); + $this->assertNotEmpty( $first ); + + // A new request on a site without a persistent object cache starts with an empty object cache. + wp_cache_flush(); + \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $this->assertSame( $first, $meta_ids() ); + } finally { + $property->setValue( null, $previous ); + } + } + } diff --git a/readme.txt b/readme.txt index 73310bc..20d691d 100644 --- a/readme.txt +++ b/readme.txt @@ -199,14 +199,13 @@ Release date: TBD * The `/wzkb/v1/knowledgebase` and `/wzkb/v1/search` REST endpoints returned the content and excerpt of password-protected articles without the password. * The `[kbtoc]` shortcode showed the headings of password-protected articles before the password was entered. -* Thumbnails could use an image from inside a password-protected article. -* [Pro] The floating table of contents, the documentation layout's "On this page" outline, and the Table of Contents widget and block showed the headings of password-protected articles before the password was entered. -* [Pro] Help widget suggested articles showed the manual excerpt of password-protected articles. +* Thumbnails could use an image from inside, or attached to, a password-protected article. +* Knowledge base listings and the `/wzkb/v1/knowledgebase` REST endpoint could show private articles to visitors from a cached copy. +* Cached knowledge base listings kept showing an article's excerpt after the article was password-protected. **Fixed** -* [Pro] GitHub imports and reimports stripped image captions from articles. -* [Pro] Ask the Docs could cite another company's article. +* The `/wzkb/v1/related` REST endpoint ignored its `post_id` and `limit` parameters. = 3.2.0 = @@ -244,4 +243,4 @@ For the changelog of earlier versions, please refer to the [releases page on Git == Upgrade Notice == = 3.2.1 = -Security release. Stops password-protected articles leaking through the REST API, tables of contents and thumbnails. Also adds faster Pro Ask the Docs answers with a choice of model and a fallback provider, and better answer caching. Pro GitHub imports preserve image captions. +Security release. Stops password-protected and private articles leaking through the REST API, listings, tables of contents and thumbnails. Also brings faster Pro Ask the Docs answers with model choice, a fallback provider and better caching, and keeps captions in Pro GitHub imports.