Make WordPress Core

Changeset 63330


Ignore:
Timestamp:
08/21/2026 05:42:32 AM (24 hours ago)
Author:
ramonopoly
Message:

Block Supports: guard against non-string attribute values to avoid fatal errors

This commit guards layout block supports values with type checks before render.

The reason is that block.json / theme.json types aren't enforced at render time, so wrong-typed values can reach strict PHP checks and cause fatals.

The cause is mainly hand-edited, imported, or AI-generated serialized content, not the block editor.

Developed in: https://github.com/WordPress/wordpress-develop/pull/12674

Props im3dabasia1, ramonopoly, westonruter.

Fixes #65774.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/block-supports/layout.php

    r62864 r63330  
    160160        }
    161161
    162         $column_start = $child_layout['columnStart'] ?? null;
    163         $column_span  = $child_layout['columnSpan'] ?? null;
     162        /*
     163         * Grid line numbers and spans are whole numbers. The editor stores them as numbers, but
     164         * content saved by WordPress 6.3 to 6.6 stored them as numeric strings, and that
     165         * migration only runs when a block is parsed in JavaScript, so the front end still sees
     166         * strings. Accept any numeric value and cast it, and treat anything else as absent
     167         * because it can't render as valid CSS.
     168         */
     169        $column_start_attr = $child_layout['columnStart'] ?? null;
     170        $column_start      = is_numeric( $column_start_attr ) ? (int) $column_start_attr : null;
     171        $column_span_attr  = $child_layout['columnSpan'] ?? null;
     172        $column_span       = is_numeric( $column_span_attr ) ? (int) $column_span_attr : null;
    164173        if ( null === $viewport_overrides || $has_viewport_property_override( 'columnStart' ) || $has_viewport_property_override( 'columnSpan' ) ) {
    165174                if ( $column_start && $column_span ) {
     
    172181        }
    173182
    174         $row_start = $child_layout['rowStart'] ?? null;
    175         $row_span  = $child_layout['rowSpan'] ?? null;
     183        $row_start_attr = $child_layout['rowStart'] ?? null;
     184        $row_start      = is_numeric( $row_start_attr ) ? (int) $row_start_attr : null;
     185        $row_span_attr  = $child_layout['rowSpan'] ?? null;
     186        $row_span       = is_numeric( $row_span_attr ) ? (int) $row_span_attr : null;
    176187        if ( null === $viewport_overrides || $has_viewport_property_override( 'rowStart' ) || $has_viewport_property_override( 'rowSpan' ) ) {
    177188                if ( $row_start && $row_span ) {
     
    191202        }
    192203
    193         $minimum_column_width = $parent_layout['minimumColumnWidth'] ?? null;
    194         $column_count         = $parent_layout['columnCount'] ?? null;
     204        $minimum_column_width_attr = $parent_layout['minimumColumnWidth'] ?? null;
     205        $minimum_column_width      = is_string( $minimum_column_width_attr ) ? $minimum_column_width_attr : null;
     206        $column_count              = $parent_layout['columnCount'] ?? null;
    195207
    196208        /*
     
    549561                }
    550562        } elseif ( 'constrained' === $layout_type ) {
    551                 $content_size    = $layout_for_styles['contentSize'] ?? '';
    552                 $wide_size       = $layout_for_styles['wideSize'] ?? '';
    553                 $justify_content = $layout_for_styles['justifyContent'] ?? 'center';
     563                // The schemas and editor UI only produce strings here, so treat a non-string
     564                // value as absent rather than casting it — it couldn't render as valid CSS anyway.
     565                $content_size_attr    = $layout_for_styles['contentSize'] ?? null;
     566                $content_size         = is_string( $content_size_attr ) ? $content_size_attr : '';
     567                $wide_size_attr       = $layout_for_styles['wideSize'] ?? null;
     568                $wide_size            = is_string( $wide_size_attr ) ? $wide_size_attr : '';
     569                $justify_content_attr = $layout_for_styles['justifyContent'] ?? null;
     570                $justify_content      = is_string( $justify_content_attr ) ? $justify_content_attr : 'center';
    554571
    555572                // Check if viewport-specific ("override") values exist. Null values are valid and mean the user cleared a value inherited from the default viewport.
     
    772789                }
    773790
     791                $flex_justify_content    = $layout_for_styles['justifyContent'] ?? null;
     792                $flex_vertical_alignment = $layout_for_styles['verticalAlignment'] ?? null;
     793
    774794                if ( 'horizontal' === $layout_orientation ) {
    775795                        /*
     
    778798                         * by custom css.
    779799                         */
    780                         if ( $should_output_flex_justification && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) {
     800                        if ( $should_output_flex_justification && ! empty( $flex_justify_content ) && is_string( $flex_justify_content ) && array_key_exists( $flex_justify_content, $justify_content_options ) ) {
    781801                                $layout_styles[] = array(
    782802                                        'selector'     => $selector,
    783                                         'declarations' => array( 'justify-content' => $justify_content_options[ $layout_for_styles['justifyContent'] ] ),
     803                                        'declarations' => array( 'justify-content' => $justify_content_options[ $flex_justify_content ] ),
    784804                                );
    785805                        }
    786806
    787                         if ( $should_output_flex_alignment && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) {
     807                        if ( $should_output_flex_alignment && ! empty( $flex_vertical_alignment ) && is_string( $flex_vertical_alignment ) && array_key_exists( $flex_vertical_alignment, $vertical_alignment_options ) ) {
    788808                                $layout_styles[] = array(
    789809                                        'selector'     => $selector,
    790                                         'declarations' => array( 'align-items' => $vertical_alignment_options[ $layout_for_styles['verticalAlignment'] ] ),
     810                                        'declarations' => array( 'align-items' => $vertical_alignment_options[ $flex_vertical_alignment ] ),
    791811                                );
    792812                        }
     
    798818                                );
    799819                        }
    800                         if ( $should_output_flex_justification && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) {
     820                        if ( $should_output_flex_justification && ! empty( $flex_justify_content ) && is_string( $flex_justify_content ) && array_key_exists( $flex_justify_content, $justify_content_options ) ) {
    801821                                $layout_styles[] = array(
    802822                                        'selector'     => $selector,
    803                                         'declarations' => array( 'align-items' => $justify_content_options[ $layout_for_styles['justifyContent'] ] ),
     823                                        'declarations' => array( 'align-items' => $justify_content_options[ $flex_justify_content ] ),
    804824                                );
    805825                        } elseif ( $should_output_flex_justification ) {
     
    809829                                );
    810830                        }
    811                         if ( $should_output_flex_alignment && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) {
     831                        if ( $should_output_flex_alignment && ! empty( $flex_vertical_alignment ) && is_string( $flex_vertical_alignment ) && array_key_exists( $flex_vertical_alignment, $vertical_alignment_options ) ) {
    812832                                $layout_styles[] = array(
    813833                                        'selector'     => $selector,
    814                                         'declarations' => array( 'justify-content' => $vertical_alignment_options[ $layout_for_styles['verticalAlignment'] ] ),
     834                                        'declarations' => array( 'justify-content' => $vertical_alignment_options[ $flex_vertical_alignment ] ),
    815835                                );
    816836                        }
    817837                }
    818838        } elseif ( 'grid' === $layout_type ) {
     839                /*
     840                 * Column and row counts are whole numbers, for the same reason as the grid line
     841                 * numbers in wp_get_child_layout_style_rules().
     842                 */
     843                $column_count_attr = $layout_for_styles['columnCount'] ?? null;
     844                $column_count      = is_numeric( $column_count_attr ) ? (int) $column_count_attr : null;
     845                $row_count_attr    = $layout_for_styles['rowCount'] ?? null;
     846                $row_count         = is_numeric( $row_count_attr ) ? (int) $row_count_attr : null;
     847
    819848                /*
    820849                 * If the gap value is an array, we use the "left" value because it represents the vertical gap, which
     
    863892                 */
    864893                $should_output_grid_columns = null === $viewport_overrides || $has_viewport_property_override( 'minimumColumnWidth' ) || $has_viewport_property_override( 'columnCount' ) || $has_viewport_property_override( 'autoFit' );
    865                 $uses_gap_in_grid_columns   = ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['minimumColumnWidth'] );
     894                $uses_gap_in_grid_columns   = ! empty( $column_count ) && ! empty( $layout_for_styles['minimumColumnWidth'] );
    866895                if ( $has_block_gap_override && $uses_gap_in_grid_columns ) {
    867896                        $should_output_grid_columns = true;
    868897                }
    869898
    870                 $should_output_grid_rows = ( null === $viewport_overrides || $has_viewport_property_override( 'rowCount' ) ) && ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['rowCount'] );
     899                $should_output_grid_rows = ( null === $viewport_overrides || $has_viewport_property_override( 'rowCount' ) ) && ! empty( $column_count ) && ! empty( $row_count );
    871900                $grid_declarations       = array();
    872901
     
    877906                $auto_placement = ! empty( $layout_for_styles['autoFit'] ) ? 'auto-fit' : 'auto-fill';
    878907
    879                 if ( $should_output_grid_columns && ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['minimumColumnWidth'] ) ) {
    880                         $max_value                                  = 'max(min(' . $layout_for_styles['minimumColumnWidth'] . ', 100%), (100% - (' . $responsive_gap_value . ' * (' . $layout_for_styles['columnCount'] . ' - 1))) /' . $layout_for_styles['columnCount'] . ')';
     908                if ( $should_output_grid_columns && ! empty( $column_count ) && ! empty( $layout_for_styles['minimumColumnWidth'] ) ) {
     909                        $max_value                                  = 'max(min(' . $layout_for_styles['minimumColumnWidth'] . ', 100%), (100% - (' . $responsive_gap_value . ' * (' . $column_count . ' - 1))) /' . $column_count . ')';
    881910                        $grid_declarations['grid-template-columns'] = 'repeat(' . $auto_placement . ', minmax(' . $max_value . ', 1fr))';
    882                 } elseif ( $should_output_grid_columns && ! empty( $layout_for_styles['columnCount'] ) ) {
    883                         $grid_declarations['grid-template-columns'] = 'repeat(' . $layout_for_styles['columnCount'] . ', minmax(0, 1fr))';
     911                } elseif ( $should_output_grid_columns && ! empty( $column_count ) ) {
     912                        $grid_declarations['grid-template-columns'] = 'repeat(' . $column_count . ', minmax(0, 1fr))';
    884913                } elseif ( $should_output_grid_columns ) {
    885914                        $minimum_column_width                       = ! empty( $layout_for_styles['minimumColumnWidth'] ) ? $layout_for_styles['minimumColumnWidth'] : '12rem';
     
    889918                if ( ! empty( $grid_declarations ) ) {
    890919                        $base_has_container_type = empty( $base_layout['columnCount'] ) || ( ! empty( $base_layout['columnCount'] ) && ! empty( $base_layout['minimumColumnWidth'] ) );
    891                         if ( empty( $layout_for_styles['columnCount'] ) || ! empty( $layout_for_styles['minimumColumnWidth'] ) ) {
     920                        if ( empty( $column_count ) || ! empty( $layout_for_styles['minimumColumnWidth'] ) ) {
    892921                                if ( null === $viewport_overrides || ! $base_has_container_type ) {
    893922                                        $grid_declarations['container-type'] = 'inline-size';
     
    903932                        $layout_styles[] = array(
    904933                                'selector'     => $selector,
    905                                 'declarations' => array( 'grid-template-rows' => 'repeat(' . $layout_for_styles['rowCount'] . ', minmax(1rem, auto))' ),
     934                                'declarations' => array( 'grid-template-rows' => 'repeat(' . $row_count . ', minmax(1rem, auto))' ),
    906935                        );
    907936                }
     
    11241153         * here.
    11251154         */
    1126         if ( ! empty( $block['attrs']['layout']['orientation'] ) ) {
    1127                 $class_names[] = 'is-' . sanitize_title( $block['attrs']['layout']['orientation'] );
    1128         }
    1129 
    1130         if ( ! empty( $block['attrs']['layout']['justifyContent'] ) ) {
    1131                 $class_names[] = 'is-content-justification-' . sanitize_title( $block['attrs']['layout']['justifyContent'] );
    1132         }
    1133 
    1134         if ( ! empty( $block['attrs']['layout']['flexWrap'] ) && 'nowrap' === $block['attrs']['layout']['flexWrap'] ) {
     1155        $orientation = $block['attrs']['layout']['orientation'] ?? null;
     1156        if ( ! empty( $orientation ) && is_string( $orientation ) ) {
     1157                $class_names[] = 'is-' . sanitize_title( $orientation );
     1158        }
     1159
     1160        $justify_content = $block['attrs']['layout']['justifyContent'] ?? null;
     1161        if ( ! empty( $justify_content ) && is_string( $justify_content ) ) {
     1162                $class_names[] = 'is-content-justification-' . sanitize_title( $justify_content );
     1163        }
     1164
     1165        $flex_wrap = $block['attrs']['layout']['flexWrap'] ?? null;
     1166        if ( ! empty( $flex_wrap ) && 'nowrap' === $flex_wrap ) {
    11351167                $class_names[] = 'is-nowrap';
    11361168        }
    11371169
    11381170        // Get classname for layout type.
    1139         if ( isset( $used_layout['type'] ) ) {
     1171        if ( isset( $used_layout['type'] ) && is_string( $used_layout['type'] ) ) {
    11401172                $layout_classname = $layout_definitions[ $used_layout['type'] ]['className'] ?? '';
    11411173        } else {
     
    14641496 */
    14651497function wp_restore_group_inner_container( $block_content, $block ) {
    1466         $tag_name                         = $block['attrs']['tagName'] ?? 'div';
     1498        $tag_name_attr                    = $block['attrs']['tagName'] ?? null;
     1499        $tag_name                         = is_string( $tag_name_attr ) ? $tag_name_attr : 'div';
    14671500        $group_with_inner_container_regex = sprintf(
    14681501                '/(^\s*<%1$s\b[^>]*wp-block-group(\s|")[^>]*>)(\s*<div\b[^>]*wp-block-group__inner-container(\s|")[^>]*>)((.|\S|\s)*)/U',
  • trunk/tests/phpunit/tests/block-supports/layout.php

    r62864 r63330  
    11891189                );
    11901190        }
     1191
     1192        /**
     1193         * Tests that a constrained layout with non-string contentSize/wideSize/justifyContent
     1194         * values (e.g. from hand-edited, imported, or AI-generated content) does not cause a
     1195         * fatal error in the explode() calls.
     1196         *
     1197         * @ticket 65774
     1198         * @covers ::wp_get_layout_style
     1199         */
     1200        public function test_wp_get_layout_style_with_non_string_constrained_sizes() {
     1201                $layout_styles = wp_get_layout_style(
     1202                        '.wp-layout',
     1203                        array(
     1204                                'type'           => 'constrained',
     1205                                'contentSize'    => array( '800px' ),
     1206                                'wideSize'       => array( '1200px' ),
     1207                                'justifyContent' => array( 'center' ),
     1208                        )
     1209                );
     1210
     1211                $this->assertIsString( $layout_styles, 'Constrained layout should not fatal when sizes are not strings.' );
     1212                $this->assertStringNotContainsString( 'Array', $layout_styles, 'A non-string size value should not leak into the output.' );
     1213        }
     1214
     1215        /**
     1216         * Tests that a flex layout with non-string justifyContent/verticalAlignment values
     1217         * does not cause a fatal error in the array_key_exists() calls.
     1218         *
     1219         * @ticket 65774
     1220         * @covers ::wp_get_layout_style
     1221         */
     1222        public function test_wp_get_layout_style_with_non_string_flex_alignment() {
     1223                $layout_styles = wp_get_layout_style(
     1224                        '.wp-layout',
     1225                        array(
     1226                                'type'              => 'flex',
     1227                                'orientation'       => 'horizontal',
     1228                                'justifyContent'    => array( 'right' ),
     1229                                'verticalAlignment' => array( 'center' ),
     1230                        )
     1231                );
     1232
     1233                $this->assertIsString( $layout_styles, 'Flex layout should not fatal when alignment values are not strings.' );
     1234        }
     1235
     1236        /**
     1237         * Tests that a responsive grid child with a non-string parent minimumColumnWidth
     1238         * does not cause a fatal error in the explode() call.
     1239         *
     1240         * @ticket 65774
     1241         * @covers ::wp_get_child_layout_style_rules
     1242         */
     1243        public function test_wp_get_child_layout_style_rules_with_non_string_minimum_column_width() {
     1244                $actual_output = wp_get_child_layout_style_rules(
     1245                        '.wp-container-content-test',
     1246                        array( 'columnSpan' => '2' ),
     1247                        array( 'minimumColumnWidth' => array( '12rem' ) ),
     1248                        null
     1249                );
     1250
     1251                $this->assertIsArray( $actual_output, 'Child layout rules should not fatal when minimumColumnWidth is not a string.' );
     1252        }
     1253
     1254        /**
     1255         * Tests that layout classname generation does not fatal when the layout type,
     1256         * orientation, or justifyContent attributes are not strings.
     1257         *
     1258         * @ticket 65774
     1259         * @covers ::wp_render_layout_support_flag
     1260         */
     1261        public function test_layout_support_flag_with_non_string_layout_values() {
     1262                $block_content = '<div class="wp-block-group"></div>';
     1263                $block         = array(
     1264                        'blockName' => 'core/group',
     1265                        'attrs'     => array(
     1266                                'layout' => array(
     1267                                        'type'           => array( 'constrained' ),
     1268                                        'orientation'    => array( 'horizontal' ),
     1269                                        'justifyContent' => array( 'center' ),
     1270                                ),
     1271                        ),
     1272                );
     1273
     1274                $this->assertIsString(
     1275                        wp_render_layout_support_flag( $block_content, $block ),
     1276                        'Layout support should not fatal when layout values are not strings.'
     1277                );
     1278        }
     1279
     1280        /**
     1281         * Tests that restoring the group inner container does not fatal when the tagName
     1282         * attribute is not a string (which would break the preg_quote() calls).
     1283         *
     1284         * @ticket 65774
     1285         * @covers ::wp_restore_group_inner_container
     1286         */
     1287        public function test_restore_group_inner_container_with_non_string_tag_name() {
     1288                // The "default" theme doesn't have theme.json support, so the preg_quote() path runs.
     1289                switch_theme( 'default' );
     1290                $block_content = '<div class="wp-block-group"><p>Test</p></div>';
     1291                $block         = array(
     1292                        'blockName' => 'core/group',
     1293                        'attrs'     => array(
     1294                                'tagName' => array( 'div' ),
     1295                        ),
     1296                );
     1297
     1298                $this->assertIsString(
     1299                        wp_restore_group_inner_container( $block_content, $block ),
     1300                        'Group inner container restore should not fatal when tagName is not a string.'
     1301                );
     1302        }
     1303
     1304        /**
     1305         * Tests that non-numeric grid placement values are dropped rather than being
     1306         * interpolated into the `grid-column` and `grid-row` declarations.
     1307         *
     1308         * @ticket 65774
     1309         * @covers ::wp_get_child_layout_style_rules
     1310         */
     1311        public function test_get_child_layout_style_rules_with_non_numeric_grid_placement() {
     1312                $actual_output = wp_get_child_layout_style_rules(
     1313                        '.wp-container-content-test',
     1314                        array(
     1315                                'columnStart' => array( 2 ),
     1316                                'columnSpan'  => array( 3 ),
     1317                                'rowStart'    => array( 1 ),
     1318                                'rowSpan'     => array( 2 ),
     1319                        ),
     1320                        array(),
     1321                        null
     1322                );
     1323
     1324                $this->assertSame(
     1325                        array(),
     1326                        $actual_output,
     1327                        'Non-numeric grid placement values should not produce any child layout rules.'
     1328                );
     1329        }
     1330
     1331        /**
     1332         * Tests that grid placement values saved as numeric strings (WordPress 6.3 to 6.6)
     1333         * produce the same declarations as numbers.
     1334         *
     1335         * @ticket 65774
     1336         * @covers ::wp_get_child_layout_style_rules
     1337         */
     1338        public function test_get_child_layout_style_rules_with_numeric_string_grid_placement() {
     1339                $expected_output = array(
     1340                        array(
     1341                                'selector'     => '.wp-container-content-test',
     1342                                'declarations' => array(
     1343                                        'grid-column' => '2 / span 3',
     1344                                        'grid-row'    => '1 / span 2',
     1345                                ),
     1346                        ),
     1347                );
     1348
     1349                $actual_output = wp_get_child_layout_style_rules(
     1350                        '.wp-container-content-test',
     1351                        array(
     1352                                'columnStart' => '2',
     1353                                'columnSpan'  => '3',
     1354                                'rowStart'    => '1',
     1355                                'rowSpan'     => '2',
     1356                        ),
     1357                        array( 'columnCount' => '3' ),
     1358                        null
     1359                );
     1360
     1361                $this->assertSame( $expected_output, $actual_output );
     1362        }
     1363
     1364        /**
     1365         * Tests that non-numeric grid counts are treated as absent instead of leaking into the
     1366         * CSS. The rowCount case keeps columnCount valid, because the row track rule is only
     1367         * reached when there is a column count.
     1368         *
     1369         * @dataProvider data_get_layout_style_with_non_numeric_grid_counts
     1370         *
     1371         * @ticket 65774
     1372         * @covers ::wp_get_layout_style
     1373         *
     1374         * @param array  $layout          Grid layout values.
     1375         * @param string $expected_output The expected output.
     1376         */
     1377        public function test_get_layout_style_with_non_numeric_grid_counts( $layout, $expected_output ) {
     1378                $this->assertSame( $expected_output, wp_get_layout_style( '.wp-layout', $layout ) );
     1379        }
     1380
     1381        /**
     1382         * Data provider for test_get_layout_style_with_non_numeric_grid_counts().
     1383         *
     1384         * @return array
     1385         */
     1386        public function data_get_layout_style_with_non_numeric_grid_counts() {
     1387                return array(
     1388                        'non-numeric columnCount falls back to the responsive default' => array(
     1389                                'layout'          => array(
     1390                                        'type'        => 'grid',
     1391                                        'columnCount' => array( 3 ),
     1392                                ),
     1393                                'expected_output' => '.wp-layout{grid-template-columns:repeat(auto-fill, minmax(min(12rem, 100%), 1fr));container-type:inline-size;}',
     1394                        ),
     1395                        'non-numeric rowCount drops the row track rule' => array(
     1396                                'layout'          => array(
     1397                                        'type'        => 'grid',
     1398                                        'columnCount' => 3,
     1399                                        'rowCount'    => array( 2 ),
     1400                                ),
     1401                                'expected_output' => '.wp-layout{grid-template-columns:repeat(3, minmax(0, 1fr));}',
     1402                        ),
     1403                );
     1404        }
     1405
     1406        /**
     1407         * Tests that grid counts saved as numeric strings (WordPress 6.3 to 6.6) produce the
     1408         * same CSS as numbers.
     1409         *
     1410         * @ticket 65774
     1411         * @covers ::wp_get_layout_style
     1412         */
     1413        public function test_get_layout_style_with_numeric_string_grid_counts() {
     1414                $layout_styles = wp_get_layout_style(
     1415                        '.wp-layout',
     1416                        array(
     1417                                'type'        => 'grid',
     1418                                'columnCount' => '3',
     1419                                'rowCount'    => '2',
     1420                        )
     1421                );
     1422
     1423                $this->assertSame(
     1424                        '.wp-layout{grid-template-columns:repeat(3, minmax(0, 1fr));grid-template-rows:repeat(2, minmax(1rem, auto));}',
     1425                        $layout_styles
     1426                );
     1427        }
    11911428}
Note: See TracChangeset for help on using the changeset viewer.