From b74d49cfa6259447e09ced01d986457f3cb5b540 Mon Sep 17 00:00:00 2001 From: Joe McGill Date: Thu, 12 Oct 2023 14:59:09 +0000 Subject: [PATCH] Grouped backports to the 6.0 branch. - REST API: Limit `search_columns` for users without `list_users`. - Comments: Prevent users who can not see a post from seeing comments on it. - Application Passwords: Prevent the use of some pseudo protocols in application passwords. - Restrict media shortcode ajax to certain type - REST API: Ensure no-cache headers are sent when methods are overriden. - Prevent unintended behavior when certain objects are unserialized. Merges [56833], [56834], [56835], [56836], [56837], and [56838] to the 6.0 branch. Props xknown, jorbin, Vortfu, joehoyle, timothyblynjacobs, peterwilsoncc, ehtis, tykoted, martinkrcho, paulkevan, dd32, antpb, rmccue. git-svn-id: https://develop.svn.wordpress.org/branches/6.0@56870 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/ajax-actions.php | 18 ++++- .../includes/class-wp-comments-list-table.php | 13 +++ src/wp-admin/includes/class-wp-list-table.php | 14 ++++ src/wp-admin/includes/dashboard.php | 12 ++- src/wp-admin/includes/user.php | 80 ++++++++++++++++--- src/wp-includes/Requests/Hooks.php | 4 + src/wp-includes/Requests/IRI.php | 14 ++++ src/wp-includes/Requests/Session.php | 4 + .../class-wp-block-patterns-registry.php | 15 ++++ .../class-wp-block-type-registry.php | 14 ++++ src/wp-includes/class-wp-theme.php | 34 ++++++++ src/wp-includes/media.php | 22 +++++ src/wp-includes/rest-api.php | 1 + .../rest-api/class-wp-rest-server.php | 42 +++++----- .../class-wp-rest-users-controller.php | 3 + src/wp-includes/shortcodes.php | 40 +++++++++- 16 files changed, 297 insertions(+), 33 deletions(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 04056eb1a45ee..3eb30edcb6667 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -3822,13 +3822,29 @@ function wp_ajax_parse_media_shortcode() { $shortcode = wp_unslash( $_POST['shortcode'] ); + // Only process previews for media related shortcodes: + $found_shortcodes = get_shortcode_tags_in_content( $shortcode ); + $media_shortcodes = array( + 'audio', + 'embed', + 'playlist', + 'video', + 'gallery', + ); + + $other_shortcodes = array_diff( $found_shortcodes, $media_shortcodes ); + + if ( ! empty( $other_shortcodes ) ) { + wp_send_json_error(); + } + if ( ! empty( $_POST['post_ID'] ) ) { $post = get_post( (int) $_POST['post_ID'] ); } // The embed shortcode requires a post. if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { - if ( 'embed' === $shortcode ) { + if ( in_array( 'embed', $found_shortcodes, true ) ) { wp_send_json_error(); } } else { diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php index 6ecccf637ba5d..506c6de52fdb6 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -640,6 +640,19 @@ public function single_row( $item ) { $this->user_can = current_user_can( 'edit_comment', $comment->comment_ID ); + $edit_post_cap = $post ? 'edit_post' : 'edit_posts'; + if ( + current_user_can( $edit_post_cap, $comment->comment_post_ID ) || + ( + empty( $post->post_password ) && + current_user_can( 'read_post', $comment->comment_post_ID ) + ) + ) { + // The user has access to the post + } else { + return false; + } + echo ""; $this->single_row_columns( $comment ); echo "\n"; diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php index 854f93064f966..98ec1459fde20 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -739,6 +739,20 @@ protected function comments_bubble( $post_id, $pending_comments ) { $pending_comments_number ); + $post_object = get_post( $post_id ); + $edit_post_cap = $post_object ? 'edit_post' : 'edit_posts'; + if ( + current_user_can( $edit_post_cap, $post_id ) || + ( + empty( $post_object->post_password ) && + current_user_can( 'read_post', $post_id ) + ) + ) { + // The user has access to the post and thus can see comments + } else { + return false; + } + if ( ! $approved_comments && ! $pending_comments ) { // No comments at all. printf( diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 2045129ea2c56..c16c6ec9addb1 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1085,7 +1085,17 @@ function wp_dashboard_recent_comments( $total_items = 5 ) { echo ''; diff --git a/src/wp-admin/includes/user.php b/src/wp-admin/includes/user.php index 64b91fce19a26..ccb048920f07c 100644 --- a/src/wp-admin/includes/user.php +++ b/src/wp-admin/includes/user.php @@ -606,6 +606,8 @@ function admin_created_user_email( $text ) { * Checks if the Authorize Application Password request is valid. * * @since 5.6.0 + * @since 6.2.0 Allow insecure HTTP connections for the local environment. + * @since 6.3.2 Validates the success and reject URLs to prevent javascript pseudo protocol being executed. * * @param array $request { * The array of request data. All arguments are optional and may be empty. @@ -621,24 +623,22 @@ function admin_created_user_email( $text ) { function wp_is_authorize_application_password_request_valid( $request, $user ) { $error = new WP_Error(); - if ( ! empty( $request['success_url'] ) ) { - $scheme = wp_parse_url( $request['success_url'], PHP_URL_SCHEME ); - - if ( 'http' === $scheme ) { + if ( isset( $request['success_url'] ) ) { + $validated_success_url = wp_is_authorize_application_redirect_url_valid( $request['success_url'] ); + if ( is_wp_error( $validated_success_url ) ) { $error->add( - 'invalid_redirect_scheme', - __( 'The success URL must be served over a secure connection.' ) + $validated_success_url->get_error_code(), + $validated_success_url->get_error_message() ); } } - if ( ! empty( $request['reject_url'] ) ) { - $scheme = wp_parse_url( $request['reject_url'], PHP_URL_SCHEME ); - - if ( 'http' === $scheme ) { + if ( isset( $request['reject_url'] ) ) { + $validated_reject_url = wp_is_authorize_application_redirect_url_valid( $request['reject_url'] ); + if ( is_wp_error( $validated_reject_url ) ) { $error->add( - 'invalid_redirect_scheme', - __( 'The rejection URL must be served over a secure connection.' ) + $validated_reject_url->get_error_code(), + $validated_reject_url->get_error_message() ); } } @@ -667,3 +667,59 @@ function wp_is_authorize_application_password_request_valid( $request, $user ) { return true; } + +/** + * Validates the redirect URL protocol scheme. The protocol can be anything except http and javascript. + * + * @since 6.3.2 + * + * @param string $url - The redirect URL to be validated. + * + * @return true|WP_Error True if the redirect URL is valid, a WP_Error object otherwise. + */ +function wp_is_authorize_application_redirect_url_valid( $url ) { + $bad_protocols = array( 'javascript', 'data' ); + if ( empty( $url ) ) { + return true; + } + + // Based on https://www.rfc-editor.org/rfc/rfc2396#section-3.1 + $valid_scheme_regex = '/^[a-zA-Z][a-zA-Z0-9+.-]*:/'; + if ( ! preg_match( $valid_scheme_regex, $url ) ) { + return new WP_Error( + 'invalid_redirect_url_format', + __( 'Invalid URL format.' ) + ); + } + + /** + * Filters the list of invalid protocols used in applications redirect URLs. + * + * @since 6.3.2 + * + * @param string[] $bad_protocols Array of invalid protocols. + * @param string $url The redirect URL to be validated. + */ + $invalid_protocols = array_map( 'strtolower', apply_filters( 'wp_authorize_application_redirect_url_invalid_protocols', $bad_protocols, $url ) ); + + $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); + $host = wp_parse_url( $url, PHP_URL_HOST ); + $is_local = 'local' === wp_get_environment_type(); + + // validates if the proper URI format is applied to the $url + if ( empty( $host ) || empty( $scheme ) || in_array( strtolower( $scheme ), $invalid_protocols, true ) ) { + return new WP_Error( + 'invalid_redirect_url_format', + __( 'Invalid URL format.' ) + ); + } + + if ( 'http' === $scheme && ! $is_local ) { + return new WP_Error( + 'invalid_redirect_scheme', + __( 'The URL must be served over a secure connection.' ) + ); + } + + return true; +} diff --git a/src/wp-includes/Requests/Hooks.php b/src/wp-includes/Requests/Hooks.php index f857902cedd1c..c94ee9b5b97aa 100644 --- a/src/wp-includes/Requests/Hooks.php +++ b/src/wp-includes/Requests/Hooks.php @@ -65,4 +65,8 @@ public function dispatch($hook, $parameters = array()) { return true; } + + public function __wakeup() { + throw new \LogicException( __CLASS__ . ' should never be unserialized' ); + } } diff --git a/src/wp-includes/Requests/IRI.php b/src/wp-includes/Requests/IRI.php index 5d80e495724bd..aa2b879101c03 100644 --- a/src/wp-includes/Requests/IRI.php +++ b/src/wp-includes/Requests/IRI.php @@ -705,6 +705,20 @@ public function is_valid() { return true; } + public function __wakeup() { + $class_props = get_class_vars( __CLASS__ ); + $string_props = array( 'scheme', 'iuserinfo', 'ihost', 'port', 'ipath', 'iquery', 'ifragment' ); + $array_props = array( 'normalization' ); + foreach ( $class_props as $prop => $default_value ) { + if ( in_array( $prop, $string_props, true ) && ! is_string( $this->$prop ) ) { + throw new UnexpectedValueException(); + } elseif ( in_array( $prop, $array_props, true ) && ! is_array( $this->$prop ) ) { + throw new UnexpectedValueException(); + } + $this->$prop = null; + } + } + /** * Set the entire IRI. Returns true on success, false on failure (if there * are any invalid characters). diff --git a/src/wp-includes/Requests/Session.php b/src/wp-includes/Requests/Session.php index b2e10991d6cc4..47c1c776d2150 100644 --- a/src/wp-includes/Requests/Session.php +++ b/src/wp-includes/Requests/Session.php @@ -229,6 +229,10 @@ public function request_multiple($requests, $options = array()) { return Requests::request_multiple($requests, $options); } + public function __wakeup() { + throw new \LogicException( __CLASS__ . ' should never be unserialized' ); + } + /** * Merge a request's data with the default data * diff --git a/src/wp-includes/class-wp-block-patterns-registry.php b/src/wp-includes/class-wp-block-patterns-registry.php index 81ee2e261c1f8..b986b1b9ef92b 100644 --- a/src/wp-includes/class-wp-block-patterns-registry.php +++ b/src/wp-includes/class-wp-block-patterns-registry.php @@ -186,6 +186,21 @@ public function is_registered( $pattern_name ) { return isset( $this->registered_patterns[ $pattern_name ] ); } + public function __wakeup() { + if ( ! $this->registered_patterns ) { + return; + } + if ( ! is_array( $this->registered_patterns ) ) { + throw new UnexpectedValueException(); + } + foreach ( $this->registered_patterns as $value ) { + if ( ! is_array( $value ) ) { + throw new UnexpectedValueException(); + } + } + $this->registered_patterns_outside_init = array(); + } + /** * Utility method to retrieve the main instance of the class. * diff --git a/src/wp-includes/class-wp-block-type-registry.php b/src/wp-includes/class-wp-block-type-registry.php index c9a5776c551c0..066eff4e51c3d 100644 --- a/src/wp-includes/class-wp-block-type-registry.php +++ b/src/wp-includes/class-wp-block-type-registry.php @@ -167,6 +167,20 @@ public function is_registered( $name ) { return isset( $this->registered_block_types[ $name ] ); } + public function __wakeup() { + if ( ! $this->registered_block_types ) { + return; + } + if ( ! is_array( $this->registered_block_types ) ) { + throw new UnexpectedValueException(); + } + foreach ( $this->registered_block_types as $value ) { + if ( ! $value instanceof WP_Block_Type ) { + throw new UnexpectedValueException(); + } + } + } + /** * Utility method to retrieve the main instance of the class. * diff --git a/src/wp-includes/class-wp-theme.php b/src/wp-includes/class-wp-theme.php index c2398fb4eb2d7..64167479b4bd4 100644 --- a/src/wp-includes/class-wp-theme.php +++ b/src/wp-includes/class-wp-theme.php @@ -713,6 +713,28 @@ public function parent() { return isset( $this->parent ) ? $this->parent : false; } + /** + * Perform reinitialization tasks. + * + * Prevents a callback from being injected during unserialization of an object. + * + * @return void + */ + public function __wakeup() { + if ( $this->parent && ! $this->parent instanceof self ) { + throw new UnexpectedValueException(); + } + if ( $this->headers && ! is_array( $this->headers ) ) { + throw new UnexpectedValueException(); + } + foreach ( $this->headers as $value ) { + if ( ! is_string( $value ) ) { + throw new UnexpectedValueException(); + } + } + $this->headers_sanitized = array(); + } + /** * Adds theme data to cache. * @@ -1771,4 +1793,16 @@ private static function _name_sort( $a, $b ) { private static function _name_sort_i18n( $a, $b ) { return strnatcasecmp( $a->name_translated, $b->name_translated ); } + + private static function _check_headers_property_has_correct_type( $headers ) { + if ( ! is_array( $headers ) ) { + return false; + } + foreach ( $headers as $key => $value ) { + if ( ! is_string( $key ) || ! is_string( $value ) ) { + return false; + } + } + return true; + } } diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index fd23af29e48e6..fba9b05e1b08b 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -2377,6 +2377,7 @@ function gallery_shortcode( $attr ) { $attachments[ $val->ID ] = $_attachments[ $key ]; } } elseif ( ! empty( $atts['exclude'] ) ) { + $post_parent_id = $id; $attachments = get_children( array( 'post_parent' => $id, @@ -2389,6 +2390,7 @@ function gallery_shortcode( $attr ) { ) ); } else { + $post_parent_id = $id; $attachments = get_children( array( 'post_parent' => $id, @@ -2401,6 +2403,17 @@ function gallery_shortcode( $attr ) { ); } + if ( ! empty( $post_parent_id ) ) { + $post_parent = get_post( $post_parent_id ); + + // terminate the shortcode execution if user cannot read the post or password-protected + if ( + ( ! is_post_publicly_viewable( $post_parent->ID ) && ! current_user_can( 'read_post', $post_parent->ID ) ) + || post_password_required( $post_parent ) ) { + return ''; + } + } + if ( empty( $attachments ) ) { return ''; } @@ -2727,6 +2740,15 @@ function wp_playlist_shortcode( $attr ) { $attachments = get_children( $args ); } + if ( ! empty( $args['post_parent'] ) ) { + $post_parent = get_post( $id ); + + // terminate the shortcode execution if user cannot read the post or password-protected + if ( ! current_user_can( 'read_post', $post_parent->ID ) || post_password_required( $post_parent ) ) { + return ''; + } + } + if ( empty( $attachments ) ) { return ''; } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 13666c0dfc3f6..a54b8f10d1ada 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -1052,6 +1052,7 @@ function rest_cookie_check_errors( $result ) { $result = wp_verify_nonce( $nonce, 'wp_rest' ); if ( ! $result ) { + add_filter( 'rest_send_nocache_headers', '__return_true', 20 ); return new WP_Error( 'rest_cookie_invalid_nonce', __( 'Cookie check failed' ), array( 'status' => 403 ) ); } diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index c38914c7bf0c9..6c7b8809d2011 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -330,24 +330,6 @@ public function serve_request( $path = null ) { $this->send_header( 'Access-Control-Allow-Headers', implode( ', ', $allow_headers ) ); - /** - * Filters whether to send nocache headers on a REST API request. - * - * @since 4.4.0 - * - * @param bool $rest_send_nocache_headers Whether to send no-cache headers. - */ - $send_no_cache_headers = apply_filters( 'rest_send_nocache_headers', is_user_logged_in() ); - if ( $send_no_cache_headers ) { - foreach ( wp_get_nocache_headers() as $header => $header_value ) { - if ( empty( $header_value ) ) { - $this->remove_header( $header ); - } else { - $this->send_header( $header, $header_value ); - } - } - } - /** * Filters whether the REST API is enabled. * @@ -402,10 +384,12 @@ public function serve_request( $path = null ) { * $_GET['_method']. If that is not set, we check for the HTTP_X_HTTP_METHOD_OVERRIDE * header. */ + $method_overridden = false; if ( isset( $_GET['_method'] ) ) { $request->set_method( $_GET['_method'] ); } elseif ( isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) { $request->set_method( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ); + $method_overridden = true; } $result = $this->check_authentication(); @@ -465,6 +449,28 @@ public function serve_request( $path = null ) { */ $served = apply_filters( 'rest_pre_serve_request', false, $result, $request, $this ); + /** + * Filters whether to send nocache headers on a REST API request. + * + * @since 4.4.0 + * @since 6.x.x Moved the block to catch the filter added on rest_cookie_check_errors() from rest-api.php + * + * @param bool $rest_send_nocache_headers Whether to send no-cache headers. + */ + $send_no_cache_headers = apply_filters( 'rest_send_nocache_headers', is_user_logged_in() ); + + // send no cache headers if the $send_no_cache_headers is true + // OR if the HTTP_X_HTTP_METHOD_OVERRIDE is used but resulted a 4xx response code. + if ( $send_no_cache_headers || ( true === $method_overridden && strpos( $code, '4' ) === 0 ) ) { + foreach ( wp_get_nocache_headers() as $header => $header_value ) { + if ( empty( $header_value ) ) { + $this->remove_header( $header ); + } else { + $this->send_header( $header, $header_value ); + } + } + } + if ( ! $served ) { if ( 'HEAD' === $request->get_method() ) { return null; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php index 5edd0fc57b706..5df26011c0855 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php @@ -318,6 +318,9 @@ public function get_items( $request ) { } if ( ! empty( $prepared_args['search'] ) ) { + if ( ! current_user_can( 'list_users' ) ) { + $prepared_args['search_columns'] = array( 'ID', 'user_login', 'user_nicename', 'display_name' ); + } $prepared_args['search'] = '*' . $prepared_args['search'] . '*'; } /** diff --git a/src/wp-includes/shortcodes.php b/src/wp-includes/shortcodes.php index 0d9fdc8148c8b..eaa2ee2641b85 100644 --- a/src/wp-includes/shortcodes.php +++ b/src/wp-includes/shortcodes.php @@ -170,7 +170,45 @@ function has_shortcode( $content, $tag ) { } /** - * Search content for shortcodes and filter shortcodes through their hooks. + * Returns a list of registered shortcode names found in the given content. + * + * Example usage: + * + * get_shortcode_tags_in_content( '[audio src="file.mp3"][/audio] [foo] [gallery ids="1,2,3"]' ); + * // array( 'audio', 'gallery' ) + * + * @since 6.3.2 + * + * @param string $content The content to check. + * @return string[] An array of registered shortcode names found in the content. + */ +function get_shortcode_tags_in_content( $content ) { + if ( false === strpos( $content, '[' ) ) { + return array(); + } + + preg_match_all( '/' . get_shortcode_regex() . '/', $content, $matches, PREG_SET_ORDER ); + if ( empty( $matches ) ) { + return array(); + } + + $tags = array(); + foreach ( $matches as $shortcode ) { + $tags[] = $shortcode[2]; + + if ( ! empty( $shortcode[5] ) ) { + $deep_tags = get_shortcode_tags_in_content( $shortcode[5] ); + if ( ! empty( $deep_tags ) ) { + $tags = array_merge( $tags, $deep_tags ); + } + } + } + + return $tags; +} + +/** + * Searches content for shortcodes and filter shortcodes through their hooks. * * This function is an alias for do_shortcode(). *