Skip to content

Commit da1ae31

Browse files
committed
wpdb: Exclude invalid identifier names.
See Core-52506. Rejects schema object names (column names, table names, etc…) provided to `wpdb::prepare()` if they are expected to be rejected by MySQL, because not all possible strings can be escaped. This change short-circuits a call to the database which would fail anyway, thus failing faster and reducing database traffic. It adds a `_doing_it_wrong()` message to notify developers of improper use.
1 parent 7be47cf commit da1ae31

File tree

1 file changed

+176
-5
lines changed

1 file changed

+176
-5
lines changed

src/wp-includes/class-wpdb.php

Lines changed: 176 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,12 +1371,13 @@ public function escape_by_ref( &$data ) {
13711371
* Quotes an identifier such as a table or field name.
13721372
*
13731373
* @since 6.2.0
1374+
* @deprecated {WP_VERSION} Use {@see self::schema_object_name_for_query()}.
13741375
*
13751376
* @param string $identifier Identifier to escape.
1376-
* @return string Escaped identifier.
1377+
* @return string|null Escaped and quoted identifier, if allowable, else `null`.
13771378
*/
13781379
public function quote_identifier( $identifier ) {
1379-
return '`' . $this->_escape_identifier_value( $identifier ) . '`';
1380+
return self::schema_object_name_for_query( $identifier, 'quote-always' );
13801381
}
13811382

13821383
/**
@@ -1388,13 +1389,168 @@ public function quote_identifier( $identifier ) {
13881389
*
13891390
* @since 6.2.0
13901391
*
1392+
* @deprecated {WP_VERSION} Use {@see self::schema_object_name_for_query()}.
1393+
*
13911394
* @link https://dev.mysql.com/doc/refman/8.0/en/identifiers.html
13921395
*
13931396
* @param string $identifier Identifier to escape.
1394-
* @return string Escaped identifier.
1397+
* @return string|null Escaped and unquoted identifier, if allowable, else `null`.
13951398
*/
13961399
private function _escape_identifier_value( $identifier ) {
1397-
return str_replace( '`', '``', $identifier );
1400+
$escaped = self::schema_object_name_for_query( $identifier, 'quote-always' );
1401+
1402+
return isset( $escaped ) ? substr( $escaped, 1, -1 ) : null;
1403+
}
1404+
1405+
/**
1406+
* Returns a version of a schema object name safe for directly including in a
1407+
* query (with no additional escaping), or `null` if given an invalid name.
1408+
*
1409+
* Not all schema object names require quoting with a backtick: this function
1410+
* only provides them when the name itself demands it. To always return the
1411+
* quoted form, pass the appropriate option.
1412+
*
1413+
* Example:
1414+
*
1415+
* 'test' === schema_object_name_for_query( 'test' );
1416+
* 'te$st' === schema_object_name_for_query( 'te$st' );
1417+
* '9dogs' === schema_object_name_for_query( '9dogs' );
1418+
* '☂' === schema_object_name_for_query( "\u{2602}" );
1419+
*
1420+
* '`%`' === schema_object_name_for_query( '%' );
1421+
* '````' === schema_object_name_for_query( '`' );
1422+
* '`$test`' === schema_object_name_for_query( '$test' );
1423+
* '`1337`' === schema_object_name_for_query( '1337' );
1424+
* '`a.b`' === schema_object_name_for_query( 'a.b' );
1425+
*
1426+
* // NUL bytes are not allowed.
1427+
* null === schema_object_name_for_query( "wp_posts\x00wp_users" );
1428+
*
1429+
* // Supplementary characters are not allowed.
1430+
* null === schema_object_name_for_query( "be\u{1F170}" );
1431+
*
1432+
* // Non-UTF8 encodings are not supported.
1433+
* null === schema_object_name_for_query( "t\xE9st" );
1434+
*
1435+
* @see https://dev.mysql.com/doc/refman/8.4/en/identifiers.html
1436+
*
1437+
* @todo Need to fall back to valid UTF-8 detection. See #6883.
1438+
*
1439+
* @since {WP_VERSION}
1440+
*
1441+
* @param string $name Schema object name to include in a query.
1442+
* @param ?('quote-always'|'quote-when-necessary') $quoting Optional. When set to `quote-always`, will return the
1443+
* quoted form of the given schema object name. Default
1444+
* is to only quote when necessary.
1445+
* @return string|null Properly escaped form of the name, if allowable, else `null`.
1446+
*/
1447+
public static function schema_object_name_for_query( string $name, string $quoting = 'quote-when-necessary' ): ?string {
1448+
/**
1449+
* Since object names are limited to 64 characters (except for
1450+
* aliases and compound statement labels, which this code
1451+
* overlooks), and since Supplementary Characters are not
1452+
* permitted, the maximum length is 64 * 3 bytes, because
1453+
* all Basic Multilingual Plane characters encode within
1454+
* three bytes.
1455+
*
1456+
* @see https://dev.mysql.com/doc/refman/8.4/en/identifiers.html
1457+
*/
1458+
static $max_object_name_bytes = 192;
1459+
1460+
/**
1461+
* MySQL interprets “characters” in an identifier as a UTF-8 code point.
1462+
* The number of bytes may be more than the number of characters.
1463+
*
1464+
* Example:
1465+
*
1466+
* "aaa" -> 3 bytes, 3 characters
1467+
* "アアア" -> 9 bytes, 3 characters
1468+
* "🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴󠁧󠁢󠁥󠁮󠁧󠁿" -> 76 bytes, 19 character
1469+
*
1470+
* @see https://dev.mysql.com/doc/refman/8.4/en/identifiers.html
1471+
*/
1472+
static $max_object_name_chars = 64;
1473+
1474+
/*
1475+
* > Internally, identifiers are converted to and are stored as Unicode (UTF-8).
1476+
*
1477+
* It’s not worth trying to convert encodings. If the given name isn’t
1478+
* already UTF-8 it isn’t supported by this function, even if it might
1479+
* be theoretically possible to convert it into UTF-8.
1480+
*/
1481+
if ( ! mb_check_encoding( $name, 'UTF-8' ) ) {
1482+
return null;
1483+
}
1484+
1485+
$name_length = strlen( $name );
1486+
if (
1487+
0 === $name_length ||
1488+
$name_length > $max_object_name_bytes ||
1489+
mb_strlen( $name, 'UTF-8' ) > $max_object_name_chars
1490+
) {
1491+
return null;
1492+
}
1493+
1494+
/*
1495+
* > Database, table, and column names cannot
1496+
* > end with space characters.
1497+
*/
1498+
if ( ' ' === $name[ $name_length - 1 ] ) {
1499+
return null;
1500+
}
1501+
1502+
$has_forbidden_characters = false;
1503+
$needs_quoting = 'quote-always' === $quoting;
1504+
$has_non_digits = false;
1505+
1506+
/*
1507+
* > Use of the dollar sign as the first character in the unquoted
1508+
* > name of a database, table, view, column, stored program, or
1509+
* > alias is deprecated, including such names used with qualifiers
1510+
*/
1511+
$needs_quoting |= '$' === $name[0];
1512+
1513+
/*
1514+
* > ASCII NUL (U+0000) and supplementary characters
1515+
* > (U+10000 and higher) are not permitted in
1516+
* > quoted or unquoted identifiers.
1517+
*/
1518+
for ( $i = 0; $i < $name_length; $i++ ) {
1519+
$c = $name[ $i ];
1520+
$o = ord( $c );
1521+
1522+
/*
1523+
* > Identifiers may begin with a digit but unless
1524+
* > quoted may not consist solely of digits.
1525+
*/
1526+
$is_digit = $c >= '0' && $c <= '9';
1527+
$has_non_digits |= ! $is_digit;
1528+
1529+
/*
1530+
* > Permitted characters in unquoted identifiers:
1531+
* > - ASCII: [0-9,a-z,A-Z$_]
1532+
* > - Extended: U+0080 .. U+FFFF
1533+
*/
1534+
$needs_quoting |= ! (
1535+
( $c >= 'A' && $c <= 'Z' ) ||
1536+
( $c >= 'a' && $c <= 'z' ) ||
1537+
$is_digit || '$' === $c || '_' === $c ||
1538+
$o >= 0x80
1539+
);
1540+
1541+
$has_forbidden_characters |= ( 0 === $o ) || ( ( $o & 0xF8 ) === 0xF0 );
1542+
}
1543+
1544+
if ( $has_forbidden_characters ) {
1545+
return null;
1546+
}
1547+
1548+
if ( ! $needs_quoting && $has_non_digits ) {
1549+
return $name;
1550+
}
1551+
1552+
$quoted_name = str_replace( '`', '``', $name );
1553+
return "`{$quoted_name}`";
13981554
}
13991555

14001556
/**
@@ -1736,7 +1892,22 @@ public function prepare( $query, ...$args ) {
17361892

17371893
foreach ( $args as $i => $value ) {
17381894
if ( in_array( $i, $arg_identifiers, true ) ) {
1739-
$args_escaped[] = $this->_escape_identifier_value( $value );
1895+
$quoted_identifier = self::schema_object_name_for_query( $value, 'quote-always' );
1896+
if ( ! isset( $quoted_identifier ) ) {
1897+
_doing_it_wrong(
1898+
'wpdb::prepare',
1899+
sprintf(
1900+
/* translators: 1: Name of database Identifier. */
1901+
__( 'The given identifier name is not allowed by MySQL: "%1$s".' ),
1902+
$value
1903+
),
1904+
'{WP_VERSION}'
1905+
);
1906+
1907+
return null;
1908+
}
1909+
1910+
$args_escaped[] = substr( $quoted_identifier, 1, -1 );
17401911
} elseif ( is_int( $value ) || is_float( $value ) ) {
17411912
$args_escaped[] = $value;
17421913
} else {

0 commit comments

Comments
 (0)