Skip to content

Commit 721d2ad

Browse files
fix: posts endpoint by allowlisting query args
1 parent 1e89095 commit 721d2ad

1 file changed

Lines changed: 112 additions & 12 deletions

File tree

inc/views/pluggable/pagination.php

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

58+
$page_number = absint( $request['page_number'] );
59+
if ( $page_number > 100 ) {
60+
return new \WP_REST_Response( '' );
61+
}
62+
5863
$query_args = $request->get_body();
5964
$args = json_decode( $query_args, true );
60-
61-
$per_page = get_option( 'posts_per_page' );
65+
$per_page = get_option( 'posts_per_page' );
6266
if ( $per_page > 100 ) {
6367
$per_page = 100;
6468
}
69+
$args = $this->sanitize_infinite_scroll_query_args( is_array( $args ) ? $args : array() );
6570

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

7580
$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';
81+
$args['paged'] = $page_number;
8482

8583
if ( ! empty( $request['lang'] ) ) {
8684
if ( defined( 'POLYLANG_VERSION' ) ) {
87-
$args['lang'] = $request['lang'];
85+
$args['lang'] = sanitize_text_field( $request['lang'] );
8886
}
8987

9088
if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {
9189
global $sitepress;
9290
if ( gettype( $sitepress ) === 'object' && method_exists( $sitepress, 'switch_lang' ) ) {
93-
$sitepress->switch_lang( $request['lang'] );
91+
$sitepress->switch_lang( sanitize_text_field( $request['lang'] ) );
9492
}
9593
}
9694
}
@@ -302,6 +300,108 @@ public function render_post_navigation() {
302300
echo '</div>';
303301
}
304302

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

0 commit comments

Comments
 (0)