-
Notifications
You must be signed in to change notification settings - Fork 88
fix: posts endpoint by allowlisting query args #4482
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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( '' ); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| $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'. | ||||||||||||||||
|
|
@@ -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'] ) ); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
@@ -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'; | ||||||||||||||||
|
||||||||||||||||
| $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
AI
Mar 5, 2026
There was a problem hiding this comment.
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.
| $safe_orderby = array( 'date', 'title', 'author', 'modified', 'comment_count', 'rand' ); | |
| $safe_orderby = array( 'date', 'title', 'author', 'modified', 'comment_count' ); |
Copilot
AI
Mar 5, 2026
There was a problem hiding this comment.
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'.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Capping
page_numberat 100 can desync with the client-sidemaxPagesvalue (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 localizedmaxPagesto 100 (or returning a signal the JS can use to stop requesting).