Skip to content

Commit 0525a08

Browse files
Merge pull request #4482 from Codeinwp/bugfix/pro/3140
fix: posts endpoint by allowlisting query args
2 parents 1e89095 + 9e42973 commit 0525a08

1 file changed

Lines changed: 111 additions & 14 deletions

File tree

inc/views/pluggable/pagination.php

Lines changed: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,14 @@ public function get_posts( \WP_REST_Request $request ) {
5555
return new \WP_REST_Response( '' );
5656
}
5757

58-
$query_args = $request->get_body();
59-
$args = json_decode( $query_args, true );
60-
61-
$per_page = get_option( 'posts_per_page' );
58+
$page_number = absint( $request['page_number'] );
59+
$query_args = $request->get_body();
60+
$args = json_decode( $query_args, true );
61+
$per_page = get_option( 'posts_per_page' );
6262
if ( $per_page > 100 ) {
6363
$per_page = 100;
6464
}
65+
$args = $this->sanitize_infinite_scroll_query_args( is_array( $args ) ? $args : array() );
6566

6667
/**
6768
* If homepage is set to 'A static page', there will be a parameter inside the query named 'pagename'.
@@ -73,24 +74,17 @@ public function get_posts( \WP_REST_Request $request ) {
7374
}
7475

7576
$args['posts_per_page'] = $per_page;
76-
77-
if ( empty( $args['post_type'] ) ) {
78-
$args['post_type'] = 'post';
79-
}
80-
81-
$args['paged'] = $request['page_number'];
82-
$args['ignore_sticky_posts'] = 1;
83-
$args['post_status'] = 'publish';
77+
$args['paged'] = $page_number;
8478

8579
if ( ! empty( $request['lang'] ) ) {
8680
if ( defined( 'POLYLANG_VERSION' ) ) {
87-
$args['lang'] = $request['lang'];
81+
$args['lang'] = sanitize_text_field( $request['lang'] );
8882
}
8983

9084
if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {
9185
global $sitepress;
9286
if ( gettype( $sitepress ) === 'object' && method_exists( $sitepress, 'switch_lang' ) ) {
93-
$sitepress->switch_lang( $request['lang'] );
87+
$sitepress->switch_lang( sanitize_text_field( $request['lang'] ) );
9488
}
9589
}
9690
}
@@ -302,6 +296,109 @@ public function render_post_navigation() {
302296
echo '</div>';
303297
}
304298

299+
/**
300+
* Sanitize query arguments for infinite scroll to prevent query manipulation.
301+
*
302+
* This method implements a strict allowlist approach to prevent:
303+
* - Expensive database queries (DoS risk via meta_query, tax_query, etc.)
304+
* - Exposure of unintended content types
305+
* - Manipulation of query parameters by anonymous users
306+
*
307+
* @param array<string, mixed> $args Raw query arguments from client request.
308+
*
309+
* @return array<string, mixed> Sanitized query arguments safe for WP_Query.
310+
*/
311+
private function sanitize_infinite_scroll_query_args( $args ) {
312+
// Define allowlist of safe query parameters for public infinite scroll.
313+
$allowed_keys = array(
314+
'category_name',
315+
'tag',
316+
's',
317+
'order',
318+
'orderby',
319+
'author',
320+
'author_name',
321+
'year',
322+
'monthnum',
323+
'day',
324+
);
325+
326+
$sanitized = array();
327+
foreach ( $allowed_keys as $key ) {
328+
if ( isset( $args[ $key ] ) ) {
329+
$sanitized[ $key ] = $args[ $key ];
330+
}
331+
}
332+
333+
if ( isset( $sanitized['category_name'] ) ) {
334+
$sanitized['category_name'] = sanitize_text_field( $sanitized['category_name'] );
335+
}
336+
if ( isset( $sanitized['tag'] ) ) {
337+
$sanitized['tag'] = sanitize_text_field( $sanitized['tag'] );
338+
}
339+
if ( isset( $sanitized['s'] ) ) {
340+
$sanitized['s'] = sanitize_text_field( $sanitized['s'] );
341+
}
342+
if ( isset( $sanitized['order'] ) ) {
343+
$order_upper = is_string( $sanitized['order'] ) ? strtoupper( $sanitized['order'] ) : '';
344+
$sanitized['order'] = in_array( $order_upper, array( 'ASC', 'DESC' ), true ) ? $order_upper : 'DESC';
345+
}
346+
if ( isset( $sanitized['orderby'] ) ) {
347+
$safe_orderby = array( 'date', 'title', 'author', 'modified', 'comment_count' );
348+
$sanitized['orderby'] = in_array( $sanitized['orderby'], $safe_orderby, true ) ? $sanitized['orderby'] : 'date';
349+
}
350+
if ( isset( $sanitized['author'] ) ) {
351+
$sanitized['author'] = absint( $sanitized['author'] );
352+
}
353+
if ( isset( $sanitized['author_name'] ) ) {
354+
$sanitized['author_name'] = sanitize_user( $sanitized['author_name'] );
355+
}
356+
if ( isset( $sanitized['year'] ) ) {
357+
$sanitized['year'] = absint( $sanitized['year'] );
358+
}
359+
if ( isset( $sanitized['monthnum'] ) ) {
360+
$sanitized['monthnum'] = absint( $sanitized['monthnum'] );
361+
}
362+
if ( isset( $sanitized['day'] ) ) {
363+
$sanitized['day'] = absint( $sanitized['day'] );
364+
}
365+
366+
$post_type = ( ! empty( $args['post_type'] ) && is_string( $args['post_type'] ) ) ? sanitize_key( $args['post_type'] ) : 'post';
367+
$post_type_obj = get_post_type_object( $post_type );
368+
369+
// Only allow if post type exists and is publicly queryable.
370+
if ( $post_type_obj && $post_type_obj->publicly_queryable ) {
371+
$sanitized['post_type'] = $post_type;
372+
} else {
373+
$sanitized['post_type'] = 'post';
374+
}
375+
376+
// Explicitly unset dangerous query args that could be smuggled in.
377+
$dangerous_keys = array_flip(
378+
array(
379+
'meta_query',
380+
'meta_key',
381+
'meta_value',
382+
'meta_value_num',
383+
'meta_compare',
384+
'tax_query',
385+
'fields',
386+
'post__in',
387+
'post__not_in',
388+
'post_parent',
389+
'post_parent__in',
390+
'post_parent__not_in',
391+
)
392+
);
393+
$sanitized = array_diff_key( $sanitized, $dangerous_keys );
394+
395+
// Force safe defaults for core query behavior.
396+
$sanitized['post_status'] = 'publish';
397+
$sanitized['ignore_sticky_posts'] = 1;
398+
399+
return $sanitized;
400+
}
401+
305402
/**
306403
* Go to page option is enabled
307404
*

0 commit comments

Comments
 (0)