Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 112 additions & 12 deletions inc/views/pluggable/pagination.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,18 @@ public function get_posts( \WP_REST_Request $request ) {
return new \WP_REST_Response( '' );
}

$page_number = absint( $request['page_number'] );
if ( $page_number > 100 ) {
return new \WP_REST_Response( '' );
}
Comment on lines +59 to +61
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capping page_number at 100 can desync with the client-side maxPages value (localized from $wp_query->max_num_pages). On sites with >100 pages, infinite scroll will keep requesting pages 101..maxPages and receive empty responses, causing unnecessary repeated requests. Consider also capping the localized maxPages to 100 (or returning a signal the JS can use to stop requesting).

Suggested change
if ( $page_number > 100 ) {
return new \WP_REST_Response( '' );
}

Copilot uses AI. Check for mistakes.

$query_args = $request->get_body();
$args = json_decode( $query_args, true );

$per_page = get_option( 'posts_per_page' );
$per_page = get_option( 'posts_per_page' );
if ( $per_page > 100 ) {
$per_page = 100;
}
$args = $this->sanitize_infinite_scroll_query_args( is_array( $args ) ? $args : array() );

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

$args['posts_per_page'] = $per_page;

if ( empty( $args['post_type'] ) ) {
$args['post_type'] = 'post';
}

$args['paged'] = $request['page_number'];
$args['ignore_sticky_posts'] = 1;
$args['post_status'] = 'publish';
$args['paged'] = $page_number;

if ( ! empty( $request['lang'] ) ) {
if ( defined( 'POLYLANG_VERSION' ) ) {
$args['lang'] = $request['lang'];
$args['lang'] = sanitize_text_field( $request['lang'] );
}

if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {
global $sitepress;
if ( gettype( $sitepress ) === 'object' && method_exists( $sitepress, 'switch_lang' ) ) {
$sitepress->switch_lang( $request['lang'] );
$sitepress->switch_lang( sanitize_text_field( $request['lang'] ) );
}
}
}
Expand Down Expand Up @@ -302,6 +300,108 @@ public function render_post_navigation() {
echo '</div>';
}

/**
* Sanitize query arguments for infinite scroll to prevent query manipulation.
*
* This method implements a strict allowlist approach to prevent:
* - Expensive database queries (DoS risk via meta_query, tax_query, etc.)
* - Exposure of unintended content types
* - Manipulation of query parameters by anonymous users
*
* @param array<string, mixed> $args Raw query arguments from client request.
*
* @return array<string, mixed> Sanitized query arguments safe for WP_Query.
*/
private function sanitize_infinite_scroll_query_args( $args ) {
// Define allowlist of safe query parameters for public infinite scroll.
$allowed_keys = array(
'category_name',
'tag',
's',
'order',
'orderby',
'author',
'author_name',
'year',
'monthnum',
'day',
);

$sanitized = array();
foreach ( $allowed_keys as $key ) {
if ( isset( $args[ $key ] ) ) {
$sanitized[ $key ] = $args[ $key ];
}
}

if ( isset( $sanitized['category_name'] ) ) {
$sanitized['category_name'] = sanitize_text_field( $sanitized['category_name'] );
}
if ( isset( $sanitized['tag'] ) ) {
$sanitized['tag'] = sanitize_text_field( $sanitized['tag'] );
}
if ( isset( $sanitized['s'] ) ) {
$sanitized['s'] = sanitize_text_field( $sanitized['s'] );
}
if ( isset( $sanitized['order'] ) ) {
$sanitized['order'] = in_array( strtoupper( $sanitized['order'] ), array( 'ASC', 'DESC' ), true ) ? $sanitized['order'] : 'DESC';
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$sanitized['order'] is passed through strtoupper() without ensuring it’s a string. A crafted JSON body can send order as an array/object, which can trigger a PHP 8 TypeError and 500 the endpoint. Add an is_string() (or is_scalar() + cast) guard before strtoupper(), and consider normalizing the stored value to ASC/DESC after validation.

Suggested change
$sanitized['order'] = in_array( strtoupper( $sanitized['order'] ), array( 'ASC', 'DESC' ), true ) ? $sanitized['order'] : 'DESC';
if ( is_scalar( $sanitized['order'] ) ) {
$order = strtoupper( (string) $sanitized['order'] );
$sanitized['order'] = in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
} else {
$sanitized['order'] = 'DESC';
}

Copilot uses AI. Check for mistakes.
}
if ( isset( $sanitized['orderby'] ) ) {
$safe_orderby = array( 'date', 'title', 'author', 'modified', 'comment_count', 'rand' );
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowlist for orderby includes rand, which in WP_Query generally results in expensive queries and can be abused for DoS on a public endpoint (contradicting the hardening goal noted in the docblock). Consider removing rand from the allowed list, or only allowing it for authenticated/privileged requests.

Suggested change
$safe_orderby = array( 'date', 'title', 'author', 'modified', 'comment_count', 'rand' );
$safe_orderby = array( 'date', 'title', 'author', 'modified', 'comment_count' );

Copilot uses AI. Check for mistakes.
$sanitized['orderby'] = in_array( $sanitized['orderby'], $safe_orderby, true ) ? $sanitized['orderby'] : 'date';
}
if ( isset( $sanitized['author'] ) ) {
$sanitized['author'] = absint( $sanitized['author'] );
}
if ( isset( $sanitized['author_name'] ) ) {
$sanitized['author_name'] = sanitize_user( $sanitized['author_name'] );
}
if ( isset( $sanitized['year'] ) ) {
$sanitized['year'] = absint( $sanitized['year'] );
}
if ( isset( $sanitized['monthnum'] ) ) {
$sanitized['monthnum'] = absint( $sanitized['monthnum'] );
}
if ( isset( $sanitized['day'] ) ) {
$sanitized['day'] = absint( $sanitized['day'] );
}

$post_type = ! empty( $args['post_type'] ) ? $args['post_type'] : 'post';
$post_type_obj = get_post_type_object( $post_type );

// Only allow if post type exists and is publicly queryable.
if ( $post_type_obj && $post_type_obj->publicly_queryable ) {
$sanitized['post_type'] = $post_type;
} else {
$sanitized['post_type'] = 'post';
}
Comment on lines +369 to +377
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

post_type is taken directly from the decoded JSON body and passed to get_post_type_object()/returned in $sanitized without type normalization. If an attacker supplies a non-string (e.g., array), this can raise warnings (array-to-string conversion) and makes the sanitization less robust. Ensure post_type is a string (e.g., is_string + sanitize_key) before using it, otherwise fall back to 'post'.

Copilot uses AI. Check for mistakes.

// Explicitly unset dangerous query args that could be smuggled in.
$dangerous_keys = array_flip(
array(
'meta_query',
'meta_key',
'meta_value',
'meta_value_num',
'meta_compare',
'tax_query',
'fields',
'post__in',
'post__not_in',
'post_parent',
'post_parent__in',
'post_parent__not_in',
)
);
$sanitized = array_diff_key( $sanitized, $dangerous_keys );

// Force safe defaults for core query behavior.
$sanitized['post_status'] = 'publish';
$sanitized['ignore_sticky_posts'] = 1;

return $sanitized;
}

/**
* Go to page option is enabled
*
Expand Down
Loading