Skip to content

Commit 73f8db1

Browse files
committed
Close remaining password-protected article disclosure paths
1 parent eb8cdba commit 73f8db1

6 files changed

Lines changed: 224 additions & 29 deletions

File tree

‎includes/frontend/class-display.php‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ public static function get_term_posts( $term ): \WP_Query {
565565

566566
// Get all the posts for the current term excluding posts located in its child terms.
567567
$args = array(
568+
'post_status' => 'publish',
568569
'posts_per_page' => -1,
569570
'no_found_rows' => true,
570571
'ignore_sticky_posts' => true,
@@ -587,19 +588,30 @@ public static function get_term_posts( $term ): \WP_Query {
587588
$query = null;
588589
$cache_enabled = ! empty( self::get_cached_option( 'cache' ) );
589590
$meta_key = null;
591+
$last_changed = wp_cache_get_last_changed( 'posts' );
590592

591593
// Support caching to speed up retrieval. Cache::get_key() includes the current language.
592594
if ( $cache_enabled ) {
593595
$meta_key = Cache::get_key( $args );
594-
$query = Cache::get( $term->term_id, $meta_key );
596+
$cached = Cache::get( $term->term_id, $meta_key );
597+
if ( is_array( $cached ) && ( $cached['last_changed'] ?? '' ) === $last_changed ) {
598+
$query = $cached['query'] ?? null;
599+
}
595600
}
596601

597602
if ( empty( $query ) ) {
598603
$query = new \WP_Query( $args );
599604

600605
// Support caching to speed up retrieval.
601606
if ( $cache_enabled ) {
602-
Cache::set( $term->term_id, $meta_key, $query );
607+
Cache::set(
608+
$term->term_id,
609+
$meta_key,
610+
array(
611+
'last_changed' => $last_changed,
612+
'query' => $query,
613+
)
614+
);
603615
}
604616
}
605617

‎includes/frontend/class-media-handler.php‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -600,8 +600,7 @@ protected static function get_thumbnail_from_content_scan( \WP_Post $post, array
600600
);
601601
}
602602

603-
// Skip content scanning for very large posts to prevent memory exhaustion, and for
604-
// password-protected posts so their images are not exposed before the password is entered.
603+
// Skip oversized or locked content before scanning for images.
605604
if ( strlen( $post->post_content ) > 50000 || post_password_required( $post ) ) { // 50KB limit.
606605
$post_content = '';
607606
} else {
@@ -661,7 +660,7 @@ protected static function get_thumbnail_from_content_scan( \WP_Post $post, array
661660
*/
662661
protected static function get_thumbnail_from_first_child( \WP_Post $post, array $args ): array {
663662
$dimensions = self::get_thumb_size( $args['size'] );
664-
$postimage = self::get_first_image( $post->ID, $dimensions[0], $dimensions[1] );
663+
$postimage = post_password_required( $post ) ? '' : self::get_first_image( $post->ID, $dimensions[0], $dimensions[1] );
665664

666665
if ( ! $postimage ) {
667666
return array(

‎includes/frontend/class-related.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ public static function get_related_articles_query( $args = array() ) {
112112
'ignore_sticky_posts' => true,
113113
'no_found_rows' => true,
114114
'post_type' => 'wz_knowledgebase',
115+
'post_status' => 'publish',
115116
'posts_per_page' => $args['numberposts'],
116117
'post__not_in' => $exclude,
117118
'orderby' => 'date',
@@ -299,7 +300,7 @@ public static function get_related_articles( $args = array() ) {
299300

300301
$output .= '</a>';
301302

302-
if ( $args['show_excerpt'] ) {
303+
if ( $args['show_excerpt'] && ! post_password_required( $post_id ) ) {
303304
$excerpt = get_the_excerpt();
304305
if ( empty( $excerpt ) ) {
305306
$content = get_post_field( 'post_content', $post_id );

‎includes/frontend/class-toc.php‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,7 @@ public function __construct() {
5252
/**
5353
* Check whether a post's content is locked behind a password for the current visitor.
5454
*
55-
* TOC renderers outside the article body use this so they do not expose the headings
56-
* of a password-protected article before the password is entered. Editors previewing
57-
* the post in the block editor still see the TOC.
55+
* Editors previewing the post in the block editor still see the TOC.
5856
*
5957
* @since 3.2.1
6058
*

‎includes/rest/class-rest-controller.php‎

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class REST_Controller {
4949
*
5050
* @since 3.2.1
5151
*/
52-
private const CACHE_KEY_VERSION = 'p2';
52+
private const CACHE_KEY_VERSION = 'p3';
5353

5454
/**
5555
* Constructor.
@@ -470,6 +470,7 @@ public function get_knowledgebase_posts( WP_REST_Request $request ) {
470470

471471
$query_args = array(
472472
'post_type' => 'wz_knowledgebase',
473+
'post_status' => 'publish',
473474
'ignore_sticky_posts' => true,
474475
'no_found_rows' => false,
475476
'posts_per_page' => $per_page,
@@ -660,29 +661,14 @@ public function get_related_articles( WP_REST_Request $request ) {
660661
);
661662
}
662663

663-
$cache_key = $this->get_cache_key(
664-
'related',
665-
array(
666-
'post_id' => $post_id,
667-
'limit' => $limit,
668-
)
669-
);
670-
671-
$cached = $this->cache_get( $cache_key );
672-
if ( false !== $cached ) {
673-
return rest_ensure_response( $cached );
674-
}
675-
664+
// Render per visitor because protected thumbnails vary with the password cookie.
676665
$related = Related::get_related_articles(
677666
array(
678-
'post_id' => $post_id,
679-
'limit' => $limit,
680-
'echo' => false,
667+
'post' => $post,
668+
'numberposts' => $limit,
681669
)
682670
);
683671

684-
$this->cache_set( $cache_key, $related );
685-
686672
return rest_ensure_response( $related );
687673
}
688674

@@ -1033,7 +1019,7 @@ private function get_post_list_args() {
10331019
* Prepare KB post data.
10341020
*
10351021
* Password-protected posts return an empty excerpt and content unless the
1036-
* requester has entered the post password, matching core's REST controller.
1022+
* requester has entered the post password using WordPress's password form.
10371023
*
10381024
* @param \WP_Post $post Post object.
10391025
* @param bool $include_content Include full content flag.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<?php
2+
/**
3+
* Tests for password protection in Knowledge Base REST responses.
4+
*
5+
* @package WebberZone\Knowledge_Base
6+
*/
7+
8+
/**
9+
* Password protection regression tests.
10+
*/
11+
class Test_WZKB_REST_Password_Protection extends WP_UnitTestCase {
12+
13+
public function test_password_cookie_does_not_unlock_other_visitors() {
14+
$post_id = self::factory()->post->create(
15+
array(
16+
'post_type' => 'wz_knowledgebase',
17+
'post_status' => 'publish',
18+
'post_title' => 'Password regression article',
19+
'post_content' => 'wzkbpasswordmarker confidential body',
20+
'post_password' => 'LocalSecret!2026',
21+
)
22+
);
23+
wp_set_current_user( 0 );
24+
$cookie_name = 'wp-postpass_' . COOKIEHASH;
25+
$old_cookie = $_COOKIE[ $cookie_name ] ?? null;
26+
$request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id );
27+
$list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' );
28+
$list->set_param( 'per_page', 50 );
29+
$search = new WP_REST_Request( 'GET', '/wzkb/v1/search' );
30+
$search->set_param( 'query', 'regression' );
31+
32+
try {
33+
unset( $_COOKIE[ $cookie_name ] );
34+
$locked = rest_get_server()->dispatch( $request );
35+
$this->assertSame( 200, $locked->get_status() );
36+
$this->assertSame( '', $locked->get_data()['content'] );
37+
$this->assertSame( '', $locked->get_data()['excerpt'] );
38+
$this->assertTrue( $locked->get_data()['protected'] );
39+
40+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
41+
require_once ABSPATH . WPINC . '/class-phpass.php';
42+
$hasher = new PasswordHash( 8, true );
43+
$_COOKIE[ $cookie_name ] = $hasher->HashPassword( 'LocalSecret!2026' );
44+
$unlocked = rest_get_server()->dispatch( $request );
45+
$this->assertStringContainsString( 'wzkbpasswordmarker', $unlocked->get_data()['content'] );
46+
foreach ( array( $list, $search ) as $collection ) {
47+
$response = rest_get_server()->dispatch( $collection );
48+
$items = array_column( $response->get_data(), null, 'id' );
49+
$this->assertStringContainsString( 'wzkbpasswordmarker', $items[ $post_id ]['excerpt'] );
50+
}
51+
52+
unset( $_COOKIE[ $cookie_name ] );
53+
$this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] );
54+
foreach ( array( $list, $search ) as $collection ) {
55+
$response = rest_get_server()->dispatch( $collection );
56+
$items = array_column( $response->get_data(), null, 'id' );
57+
$this->assertSame( '', $items[ $post_id ]['excerpt'] );
58+
}
59+
} finally {
60+
if ( null === $old_cookie ) {
61+
unset( $_COOKIE[ $cookie_name ] );
62+
} else {
63+
$_COOKIE[ $cookie_name ] = $old_cookie;
64+
}
65+
}
66+
}
67+
68+
public function test_adding_a_password_invalidates_open_responses() {
69+
$post_id = self::factory()->post->create(
70+
array(
71+
'post_type' => 'wz_knowledgebase',
72+
'post_status' => 'publish',
73+
'post_title' => 'Password transition article',
74+
'post_content' => 'wzkbtransitionmarker confidential body',
75+
)
76+
);
77+
wp_set_current_user( 0 );
78+
$single = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id );
79+
$list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' );
80+
$list->set_param( 'search', 'transition' );
81+
$this->assertStringContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $single )->get_data() ) );
82+
$this->assertStringContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $list )->get_data() ) );
83+
84+
wp_update_post( array( 'ID' => $post_id, 'post_password' => 'new-password' ) );
85+
86+
$this->assertSame( '', rest_get_server()->dispatch( $single )->get_data()['content'] );
87+
$this->assertStringNotContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $list )->get_data() ) );
88+
}
89+
public function test_public_collection_does_not_cache_private_articles() {
90+
$post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'private', 'post_content' => 'wzkbprivatemarker' ) );
91+
$request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' );
92+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
93+
rest_get_server()->dispatch( $request );
94+
wp_set_current_user( 0 );
95+
$response = rest_get_server()->dispatch( $request );
96+
$this->assertNotContains( $post_id, array_column( $response->get_data(), 'id' ) );
97+
}
98+
99+
public function test_related_rest_does_not_cache_unlocked_body_images() {
100+
$source = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish' ) );
101+
$protected = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_password' => 'secret', 'post_content' => '<img src="https://example.com/wzkbprotectedimage.png" />' ) );
102+
$request = new WP_REST_Request( 'GET', '/wzkb/v1/related' );
103+
$request->set_param( 'post_id', $source );
104+
$request->set_param( 'limit', 5 );
105+
wp_set_current_user( 0 );
106+
require_once ABSPATH . WPINC . '/class-phpass.php';
107+
$hasher = new PasswordHash( 8, true );
108+
$cookie_name = 'wp-postpass_' . COOKIEHASH;
109+
$old_cookie = $_COOKIE[ $cookie_name ] ?? null;
110+
try {
111+
$_COOKIE[ $cookie_name ] = $hasher->HashPassword( 'secret' );
112+
$unlocked = rest_get_server()->dispatch( $request );
113+
$this->assertStringContainsString( 'wzkbprotectedimage', $unlocked->get_data() );
114+
unset( $_COOKIE[ $cookie_name ] );
115+
$this->assertStringNotContainsString( 'wzkbprotectedimage', rest_get_server()->dispatch( $request )->get_data() );
116+
} finally {
117+
if ( null === $old_cookie ) {
118+
unset( $_COOKIE[ $cookie_name ] );
119+
} else {
120+
$_COOKIE[ $cookie_name ] = $old_cookie;
121+
}
122+
}
123+
}
124+
125+
public function test_route_filters_and_taxonomy_parameters_cannot_unlock_content() {
126+
$post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_content' => 'wzkbparammarker', 'post_password' => 'secret' ) );
127+
$section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) );
128+
$product = self::factory()->term->create( array( 'taxonomy' => 'wzkb_product' ) );
129+
wp_set_object_terms( $post_id, array( $section ), 'wzkb_category' );
130+
wp_set_object_terms( $post_id, array( $product ), 'wzkb_product' );
131+
add_filter( 'wzkb_rest_route_permission', '__return_true' );
132+
foreach ( array( 0, self::factory()->user->create( array( 'role' => 'subscriber' ) ), self::factory()->user->create( array( 'role' => 'contributor' ) ) ) as $user_id ) {
133+
wp_set_current_user( $user_id );
134+
foreach ( array( '', 'en', 'fr' ) as $language ) {
135+
$request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id );
136+
$request->set_param( 'lang', $language );
137+
$request->set_param( 'context', 'edit' );
138+
$request->set_param( 'password', 'secret' );
139+
$this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] );
140+
$list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' );
141+
$list->set_param( 'product', $product );
142+
$list->set_param( 'section', $section );
143+
$list->set_param( 'per_page', 1 );
144+
$list->set_param( 'page', 1 );
145+
$list->set_param( 'lang', $language );
146+
$response = rest_get_server()->dispatch( $list );
147+
$this->assertSame( 200, $response->get_status() );
148+
$this->assertSame( '', $response->get_data()[0]['excerpt'] );
149+
$this->assertSame( '1', $response->get_headers()['X-WP-Total'] );
150+
}
151+
}
152+
}
153+
154+
public function test_password_removal_restores_content() {
155+
$post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_content' => 'wzkbremovedpassword', 'post_password' => 'secret' ) );
156+
wp_set_current_user( 0 );
157+
$request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id );
158+
$this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] );
159+
wp_update_post( array( 'ID' => $post_id, 'post_password' => '' ) );
160+
$this->assertStringContainsString( 'wzkbremovedpassword', rest_get_server()->dispatch( $request )->get_data()['content'] );
161+
}
162+
163+
public function test_attached_image_fallback_does_not_expose_locked_images() {
164+
$post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_password' => 'secret' ) );
165+
$attachment = self::factory()->post->create( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_parent' => $post_id, 'post_mime_type' => 'image/jpeg' ) );
166+
update_post_meta( $attachment, '_wp_attached_file', 'wzkbsecretattachment.jpg' );
167+
wp_set_current_user( 0 );
168+
$html = \WebberZone\Knowledge_Base\Frontend\Media_Handler::get_the_post_thumbnail( array( 'post' => $post_id ) );
169+
$this->assertStringNotContainsString( 'wzkbsecretattachment', $html );
170+
wp_update_post( array( 'ID' => $post_id, 'post_password' => '' ) );
171+
$html = \WebberZone\Knowledge_Base\Frontend\Media_Handler::get_the_post_thumbnail( array( 'post' => $post_id ) );
172+
$this->assertStringContainsString( 'wzkbsecretattachment', $html );
173+
}
174+
175+
public function test_section_query_refreshes_when_an_article_gains_a_password() {
176+
$section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) );
177+
$id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_excerpt' => 'wzkbstalesectionmarker' ) );
178+
wp_set_object_terms( $id, array( $section ), 'wzkb_category' );
179+
wp_set_current_user( 0 );
180+
$property = new ReflectionProperty( \WebberZone\Knowledge_Base\Frontend\Display::class, 'options_cache' );
181+
if ( PHP_VERSION_ID < 80100 ) {
182+
$property->setAccessible( true );
183+
}
184+
$previous = $property->getValue();
185+
$property->setValue( null, array( 'cache' => true ) );
186+
try {
187+
$term = get_term( $section );
188+
$query = \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term );
189+
$this->assertSame( '', $query->posts[0]->post_password );
190+
wp_update_post( array( 'ID' => $id, 'post_password' => 'secret' ) );
191+
$query = \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term );
192+
$this->assertSame( 'secret', $query->posts[0]->post_password );
193+
$this->assertStringNotContainsString( 'wzkbstalesectionmarker', get_the_excerpt( $query->posts[0] ) );
194+
} finally {
195+
$property->setValue( null, $previous );
196+
}
197+
}
198+
199+
}

0 commit comments

Comments
 (0)