Make WordPress Core

Changeset 63708


Ignore:
Timestamp:
09/17/2026 09:05:55 PM (3 hours ago)
Author:
desrosj
Message:

Security: Backport the WordPress 7.1.1 security fixes to the 5.4 branch.

  • Posts, Post Types: Reject a supplied post ID on the create path in _wp_translate_postdata().
  • Comments: Enforce target post permissions when updating notes via REST.
  • XML-RPC: Reject writes to internal-only builtin post types.
  • Administration: Add authorization check to wp_ajax_sample_permalink().
  • Customize: Improve header_image_data theme mod sanitization.
  • Themes: Escape the theme installer preview route value.
  • Plugins: Require network plugin authority to Ajax-activate a network-only plugin.
  • Formatting: Prevent wpautop() moving a paragraph into an attribute of a blockquote.

Merges r63657, r63658, r63659, r63660, r63665, r63669, r63672, r63675 to the 5.4 branch.

Props xknown, westonruter, jorbin, vortfu, batmoo, davidbinda, jeremyfelt, adamsilverstein, ramonopoly, villanovachile, johnbillion, peterwilsoncc, lancewillett, jonsurrell, dmsnell, whyisjake, buffer1024, joehoyle, rafiem.

Location:
branches/5.4
Files:
11 edited

Legend:

Unmodified
Added
Removed
  • branches/5.4

  • branches/5.4/src/js/_enqueues/wp/theme.js

    r47122 r63708  
    19321932
    19331933
     1934/*
     1935 * jQuery.escapeSelector() (added in jQuery 3.0) is not available on this
     1936 * branch's vendored jQuery. Fall back to the native CSS.escape(), which is
     1937 * what jQuery.escapeSelector() delegates to and which jQuery UI polyfills
     1938 * it with. A browser with neither skips the preview route below rather
     1939 * than building a selector from an unescaped slug.
     1940 */
     1941var escapeSelector = $.escapeSelector || ( window.CSS && window.CSS.escape && function( sel ) {
     1942        return window.CSS.escape( sel + '' );
     1943} );
     1944
    19341945themes.RunInstaller = {
    19351946
     
    19942005                                self.view.collection.trigger( 'update' );
    19952006
    1996                                 // Open the theme preview.
     2007                                // Open the theme preview. The slug comes from the URL, so escape it.
    19972008                                self.view.collection.once( 'query:success', function() {
    1998                                         $( 'div[data-slug="' + slug + '"]' ).trigger( 'click' );
     2009                                        if ( ! escapeSelector ) {
     2010                                                return;
     2011                                        }
     2012
     2013                                        $( 'div.theme[data-slug="' + escapeSelector( slug ) + '"]' ).trigger( 'click' );
    19992014                                });
    20002015
  • branches/5.4/src/wp-admin/includes/ajax-actions.php

    r56878 r63708  
    19521952        check_ajax_referer( 'getpermalink', 'getpermalinknonce' );
    19531953        $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
     1954        if ( ! $post_id ) {
     1955                // Bypass call to get_preview_post_link() for unspecified post ID.
     1956                wp_die( '' );
     1957        }
     1958        if ( ! current_user_can( 'edit_post', $post_id ) ) {
     1959                wp_die( -1 );
     1960        }
    19541961        wp_die( get_preview_post_link( $post_id ) );
    19551962}
     
    19631970        check_ajax_referer( 'samplepermalink', 'samplepermalinknonce' );
    19641971        $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
    1965         $title   = isset( $_POST['new_title'] ) ? $_POST['new_title'] : '';
    1966         $slug    = isset( $_POST['new_slug'] ) ? $_POST['new_slug'] : null;
     1972        if ( ! $post_id ) {
     1973                // Bypass call to get_sample_permalink_html() for unspecified post ID.
     1974                wp_die( '' );
     1975        }
     1976        if ( ! current_user_can( 'edit_post', $post_id ) ) {
     1977                wp_die( -1 );
     1978        }
     1979        $title = isset( $_POST['new_title'] ) ? $_POST['new_title'] : '';
     1980        $slug  = isset( $_POST['new_slug'] ) ? $_POST['new_slug'] : null;
    19671981        wp_die( get_sample_permalink_html( $post_id, $title, $slug ) );
    19681982}
  • branches/5.4/src/wp-admin/includes/class-custom-image-header.php

    r49391 r63708  
    529529                                }
    530530                                ?>
    531         <div id="headimg" style="<?php echo $header_image_style; ?>">
     531        <div id="headimg" style="<?php echo esc_attr( $header_image_style ); ?>">
    532532                                <?php
    533533                                if ( display_header_text() ) {
     
    10881088                                'url'           => $choice['url'],
    10891089                                'thumbnail_url' => $choice['url'],
    1090                                 'height'        => $choice['height'],
    1091                                 'width'         => $choice['width'],
     1090                                'height'        => absint( $choice['height'] ),
     1091                                'width'         => absint( $choice['width'] ),
    10921092                        );
    10931093
     
    11171117                }
    11181118
    1119                 set_theme_mod( 'header_image', esc_url_raw( $header_image_data['url'] ) );
     1119                $header_image_data['url'] = esc_url_raw( $header_image_data['url'] );
     1120
     1121                if ( isset( $header_image_data['thumbnail_url'] ) ) {
     1122                        $header_image_data['thumbnail_url'] = esc_url_raw( $header_image_data['thumbnail_url'] );
     1123                }
     1124
     1125                set_theme_mod( 'header_image', $header_image_data['url'] );
    11201126                set_theme_mod( 'header_image_data', $header_image_data );
    11211127        }
     
    14781484
    14791485                foreach ( $header_images as &$header_image ) {
    1480                         $header_meta               = get_post_meta( $header_image['attachment_id'] );
    1481                         $header_image['timestamp'] = isset( $header_meta[ $timestamp_key ] ) ? $header_meta[ $timestamp_key ] : '';
    1482                         $header_image['alt_text']  = isset( $header_meta[ $alt_text_key ] ) ? $header_meta[ $alt_text_key ] : '';
     1486                        $header_image['timestamp'] = get_post_meta( $header_image['attachment_id'], $timestamp_key, true );
     1487                        $header_image['alt_text']  = get_post_meta( $header_image['attachment_id'], $alt_text_key, true );
    14831488                }
    14841489
  • branches/5.4/src/wp-admin/includes/plugin.php

    r47219 r63708  
    584584 *
    585585 * @since 3.0.0
    586  *
    587  * @param string $plugin Path to the plugin file relative to the plugins directory.
     586 * @since 7.1.1 The `$plugin` path is normalized with `plugin_basename()` and `trim()`,
     587 *              matching how `activate_plugin()` resolves it.
     588 *
     589 * @param string $plugin Path to the plugin file. Accepts a path relative to the plugins
     590 *                       directory, or an absolute path, with or without surrounding whitespace.
    588591 * @return bool True if plugin is network only, false otherwise.
    589592 */
    590593function is_network_only_plugin( $plugin ) {
     594        // Normalize the path the same way activate_plugin() does, so both agree on the file.
     595        $plugin = plugin_basename( trim( $plugin ) );
     596
    591597        $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin );
    592598        if ( $plugin_data ) {
  • branches/5.4/src/wp-admin/includes/post.php

    r54559 r63708  
    2222        if ( empty( $post_data ) ) {
    2323                $post_data = &$_POST;
     24        }
     25
     26        /*
     27         * A raw `ID` on the create path (no `post_ID`) is an attempt to overwrite an
     28         * existing post while bypassing the per-post capability checks below, which only
     29         * run on the update path. Reject it outright: legitimate post creation never
     30         * carries an `ID`.
     31         */
     32        if ( ! $update && ! empty( $post_data['ID'] ) ) {
     33                if ( 'page' === $post_data['post_type'] ) {
     34                        return new WP_Error( 'edit_others_pages', __( 'Sorry, you are not allowed to edit pages as this user.' ) );
     35                } else {
     36                        return new WP_Error( 'edit_others_posts', __( 'Sorry, you are not allowed to edit posts as this user.' ) );
     37                }
    2438        }
    2539
  • branches/5.4/src/wp-includes/class-wp-xmlrpc-server.php

    r49391 r63708  
    13931393                }
    13941394
     1395                // Reject writes to internal-only builtin post types (e.g. customize_changeset)
     1396                // whose intended write path is a dedicated helper, not a generic post API.
     1397                $is_internal_only = (
     1398                        empty( $post_type->public )
     1399                        && empty( $post_type->show_in_rest )
     1400                        && ! empty( $post_type->_builtin )
     1401                );
     1402
     1403                /**
     1404                 * Filters whether a post type accepts writes via XML-RPC.
     1405                 *
     1406                 * Defaults to false for internal-only builtin post types (public=false,
     1407                 * show_in_rest=false, _builtin=true), such as customize_changeset, whose
     1408                 * writes are meant to flow through dedicated helpers. Return true to opt
     1409                 * a post type back in.
     1410                 *
     1411                 * @since 7.1.1
     1412                 *
     1413                 * @param bool         $allowed   Whether the post type accepts XML-RPC writes.
     1414                 * @param WP_Post_Type $post_type The post type object.
     1415                 */
     1416                $allowed = apply_filters( 'xmlrpc_allow_post_type_writes', ! $is_internal_only, $post_type );
     1417
     1418                if ( ! $allowed ) {
     1419                        return new IXR_Error( 403, __( 'Sorry, this post type is not supported over XML-RPC.' ) );
     1420                }
     1421
    13951422                $update = ! empty( $post_data['ID'] );
    13961423
  • branches/5.4/src/wp-includes/customize/class-wp-customize-header-image-setting.php

    r47198 r63708  
    1616 *
    1717 * @see WP_Customize_Setting
     18 *
     19 * @phpstan-type Header_Image_Data array{
     20 *     attachment_id?: int,
     21 *     url?: string,
     22 *     thumbnail_url?: string,
     23 *     timestamp?: int,
     24 *     width?: int,
     25 *     height?: int,
     26 *     alt_text?: string,
     27 *     attachment_parent?: int,
     28 * }
    1829 */
    1930final class WP_Customize_Header_Image_Setting extends WP_Customize_Setting {
     
    5162                }
    5263        }
     64
     65        /**
     66         * Sanitizes a header value.
     67         *
     68         * The value is expected to be one of the following:
     69         *
     70         * - An array of header image data, with the keys `attachment_id`, `url`, `thumbnail_url`, `timestamp`, `width`,
     71         *   `height`, `alt_text`, and `attachment_parent`, as supplied by {@see get_uploaded_header_images()}. Any other
     72         *   key is discarded.
     73         * - An array with a `choice` key, being the legacy format in which any of the other accepted values is nested.
     74         * - The string `remove-header`, `random-default-image`, or `random-uploaded-image`.
     75         * - A string corresponding to one of the keys for the array returned by {@see get_uploaded_header_images()}, or
     76         *   one of the keys for the array passed into {@see register_default_headers()}.
     77         *
     78         * @since 7.1.1
     79         *
     80         * @see WP_Customize_Header_Image_Setting::update()
     81         * @see Custom_Image_Header::set_header_image()
     82         *
     83         * @param mixed $value Value to sanitize.
     84         * @return array|string|WP_Error|null Sanitized value, or `null`/`WP_Error` if invalid. The array holds
     85         *                                    the header image data, or that data nested under a `choice` key,
     86         *                                    before the `customize_sanitize_header_image_data` filter, which
     87         *                                    may return anything, is applied to it.
     88         *
     89         * @phpstan-return array<mixed, mixed>|string|WP_Error|null
     90         */
     91        public function sanitize( $value ) {
     92                /*
     93                 * The update() method unwraps the legacy `choice` format before handing the value off to
     94                 * Custom_Image_Header::set_header_image(), so the nested value is what must be sanitized.
     95                 */
     96                if ( is_array( $value ) && isset( $value['choice'] ) ) {
     97                        $choice = $this->sanitize_choice( $value['choice'] );
     98                        if ( is_null( $choice ) || is_wp_error( $choice ) ) {
     99                                return $choice;
     100                        }
     101                        $value = array( 'choice' => $choice );
     102                } else {
     103                        $value = $this->sanitize_choice( $value );
     104                        if ( is_null( $value ) || is_wp_error( $value ) ) {
     105                                return $value;
     106                        }
     107                }
     108
     109                return parent::sanitize( $value );
     110        }
     111
     112        /**
     113         * Sanitizes a header image choice.
     114         *
     115         * This is the value which is ultimately passed to {@see Custom_Image_Header::set_header_image()}, whether
     116         * supplied at the top level of the setting value or nested under its legacy `choice` key.
     117         *
     118         * @since 7.1.1
     119         *
     120         * @param mixed $value Value to sanitize.
     121         * @return array|string|WP_Error|null Sanitized value, or `null`/`WP_Error` if invalid.
     122         *
     123         * @phpstan-return Header_Image_Data|string|WP_Error|null
     124         */
     125        private function sanitize_choice( $value ) {
     126                // Custom_Image_Header::set_header_image() accepts an object in place of an array.
     127                if ( is_object( $value ) ) {
     128                        $value = (array) $value;
     129                }
     130
     131                if ( is_string( $value ) ) {
     132                        return sanitize_text_field( $value );
     133                }
     134
     135                if ( ! is_array( $value ) ) {
     136                        return null;
     137                }
     138
     139                /*
     140                 * The sanitized value is assembled member by member rather than filtered down from the
     141                 * supplied one, so that nothing but the members below can end up in it.
     142                 */
     143                $sanitized = array();
     144
     145                if ( isset( $value['attachment_id'] ) ) {
     146                        if ( ! is_scalar( $value['attachment_id'] ) ) {
     147                                return null;
     148                        }
     149                        $attachment_id = absint( $value['attachment_id'] );
     150
     151                        /*
     152                         * A supplied attachment must be an existing image, since its ID is written to postmeta and its
     153                         * data displayed. Note that an ID of zero must be skipped rather than looked up, as
     154                         * get_post_mime_type() falls back to the global post when passed an empty value.
     155                         */
     156                        if ( $attachment_id > 0 ) {
     157                                $mime_type = get_post_mime_type( $attachment_id );
     158                                if ( ! is_string( $mime_type ) || ! str_starts_with( $mime_type, 'image/' ) ) {
     159                                        return null;
     160                                }
     161                        }
     162
     163                        $sanitized['attachment_id'] = $attachment_id;
     164                }
     165
     166                if ( isset( $value['url'] ) ) {
     167                        if ( ! is_string( $value['url'] ) ) {
     168                                return null;
     169                        }
     170                        $sanitized['url'] = esc_url_raw( $value['url'] );
     171                        if ( '' === $sanitized['url'] ) {
     172                                return new WP_Error( 'invalid_url', __( 'Invalid URL.' ) );
     173                        }
     174                }
     175
     176                if ( isset( $value['thumbnail_url'] ) ) {
     177                        if ( ! is_string( $value['thumbnail_url'] ) ) {
     178                                return null;
     179                        }
     180                        $sanitized['thumbnail_url'] = esc_url_raw( $value['thumbnail_url'] );
     181                        if ( '' === $sanitized['thumbnail_url'] ) {
     182                                return new WP_Error( 'invalid_url', __( 'Invalid URL.' ) );
     183                        }
     184                }
     185
     186                if ( isset( $value['timestamp'] ) ) {
     187                        if ( ! is_scalar( $value['timestamp'] ) ) {
     188                                return null;
     189                        }
     190                        $sanitized['timestamp'] = absint( $value['timestamp'] );
     191                }
     192
     193                if ( isset( $value['width'] ) ) {
     194                        if ( ! is_scalar( $value['width'] ) ) {
     195                                return null;
     196                        }
     197                        $sanitized['width'] = absint( $value['width'] );
     198                }
     199
     200                if ( isset( $value['height'] ) ) {
     201                        if ( ! is_scalar( $value['height'] ) ) {
     202                                return null;
     203                        }
     204                        $sanitized['height'] = absint( $value['height'] );
     205                }
     206
     207                if ( isset( $value['alt_text'] ) ) {
     208                        if ( ! is_string( $value['alt_text'] ) ) {
     209                                return null;
     210                        }
     211                        $sanitized['alt_text'] = sanitize_text_field( $value['alt_text'] );
     212                }
     213
     214                if ( isset( $value['attachment_parent'] ) ) {
     215                        if ( ! is_scalar( $value['attachment_parent'] ) ) {
     216                                return null;
     217                        }
     218                        $sanitized['attachment_parent'] = absint( $value['attachment_parent'] );
     219                }
     220
     221                return $sanitized;
     222        }
    53223}
  • branches/5.4/src/wp-includes/formatting.php

    r55785 r63708  
    573573
    574574        // If a <blockquote> is wrapped with a <p>, move it inside the <blockquote>.
    575         $pee = preg_replace( '|<p><blockquote([^>]*)>|i', '<blockquote$1><p>', $pee );
     575        $pee = preg_replace( '!<p><blockquote((?:[^>"\']|"[^"]*"|\'[^\']*\')*)>!i', '<blockquote$1><p>', $pee );
    576576        $pee = str_replace( '</blockquote></p>', '</p></blockquote>', $pee );
    577577
  • branches/5.4/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php

    r47391 r63708  
    769769         *
    770770         * @since 4.7.0
     771         * @since 7.1.0 Target post permissions are checked when a comment's parent post is changed.
    771772         *
    772773         * @param WP_REST_Request $request Full details about the request.
     
    785786                                array( 'status' => rest_authorization_required_code() )
    786787                        );
     788                }
     789
     790                /*
     791                 * check_edit_permission() above only establishes that the comment may be
     792                 * edited where it currently sits, because 'edit_comment' maps to 'edit_post'
     793                 * on the comment's current parent. When the parent is being changed, the new
     794                 * parent has to be authorized as well. Without this, a user holding
     795                 * edit_comment on their own comment could reparent it onto any post,
     796                 * including posts they can neither read nor edit.
     797                 */
     798                if ( isset( $request['post'] ) && (int) $request['post'] !== (int) $comment->comment_post_ID ) {
     799                        $target_check = $this->check_target_post_permission( (int) $request['post'] );
     800
     801                        if ( is_wp_error( $target_check ) ) {
     802                                return $target_check;
     803                        }
    787804                }
    788805
     
    18691886                return $email;
    18701887        }
     1888
     1889        /**
     1890         * Checks that a post can receive a comment from the current user.
     1891         *
     1892         * Used when changing the parent post of an existing comment, so that
     1893         * attaching a comment to a post is authorized the same way whichever
     1894         * path it arrives by.
     1895         *
     1896         * @since 7.1.0
     1897         *
     1898         * @param int $post_id Target post ID.
     1899         * @return true|WP_Error True if the post can receive the comment, error object otherwise.
     1900         */
     1901        protected function check_target_post_permission( $post_id ) {
     1902                if ( ! $post_id ) {
     1903                        return new WP_Error(
     1904                                'rest_comment_invalid_post_id',
     1905                                __( 'Sorry, you are not allowed to create this comment without a post.' ),
     1906                                array( 'status' => 403 )
     1907                        );
     1908                }
     1909
     1910                /*
     1911                 * A comment needs either comment moderation rights or edit access to the
     1912                 * post, which is what check_edit_permission() grants on the post a comment
     1913                 * is moving away from. Requiring the same at the destination means both
     1914                 * ends of a move are authorized alike.
     1915                 */
     1916                if ( ! current_user_can( 'moderate_comments' ) && ! current_user_can( 'edit_post', $post_id ) ) {
     1917                        return new WP_Error(
     1918                                'rest_cannot_edit',
     1919                                __( 'Sorry, you are not allowed to edit this comment.' ),
     1920                                array( 'status' => rest_authorization_required_code() )
     1921                        );
     1922                }
     1923
     1924                $post = get_post( $post_id );
     1925
     1926                if ( ! $post ) {
     1927                        return new WP_Error(
     1928                                'rest_comment_invalid_post_id',
     1929                                __( 'Sorry, you are not allowed to create this comment without a post.' ),
     1930                                array( 'status' => 403 )
     1931                        );
     1932                }
     1933
     1934                /*
     1935                 * The create-time draft and comments-open rules are deliberately not applied
     1936                 * here, because moderators move comments onto posts whose discussion has
     1937                 * closed and onto drafts today. Enforcing them would break that without
     1938                 * blocking anything the capability check above already permits.
     1939                 */
     1940                return true;
     1941        }
    18711942}
  • branches/5.4/src/wp-includes/theme.php

    r47267 r63708  
    13221322 *
    13231323 * @since 3.4.0
     1324 * @since 7.1.1 The `width` and `height` are cast to non-negative integers.
    13241325 *
    13251326 * @global array $_wp_default_headers
     
    13601361                'video'         => get_theme_support( 'custom-header', 'video' ),
    13611362        );
    1362         return (object) wp_parse_args( $data, $default );
     1363
     1364        if ( ! is_array( $data ) && ! is_object( $data ) ) {
     1365                $data = array();
     1366        }
     1367        $header         = (object) wp_parse_args( $data, $default );
     1368        $header->width  = absint( $header->width );
     1369        $header->height = absint( $header->height );
     1370        return $header;
    13631371}
    13641372
Note: See TracChangeset for help on using the changeset viewer.