Changeset 55151 for trunk/src/wp-includes/class-wpdb.php
- Timestamp:
- 01/27/2023 06:47:53 PM (2 years ago)
- File:
-
- 1 edited
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/class-wpdb.php
r54950 r55151 656 656 657 657 /** 658 * Backward compatibility, where wpdb::prepare() has not quoted formatted/argnum placeholders. 659 * 660 * This is often used for table/field names (before %i was supported), and sometimes string formatting, e.g. 661 * 662 * $wpdb->prepare( 'WHERE `%1$s` = "%2$s something %3$s" OR %1$s = "%4$-10s"', 'field_1', 'a', 'b', 'c' ); 663 * 664 * But it's risky, e.g. forgetting to add quotes, resulting in SQL Injection vulnerabilities: 665 * 666 * $wpdb->prepare( 'WHERE (id = %1s) OR (id = %2$s)', $_GET['id'], $_GET['id'] ); // ?id=id 667 * 668 * This feature is preserved while plugin authors update their code to use safer approaches: 669 * 670 * $_GET['key'] = 'a`b'; 671 * 672 * $wpdb->prepare( 'WHERE %1s = %s', $_GET['key'], $_GET['value'] ); // WHERE a`b = 'value' 673 * $wpdb->prepare( 'WHERE `%1$s` = "%2$s"', $_GET['key'], $_GET['value'] ); // WHERE `a`b` = "value" 674 * 675 * $wpdb->prepare( 'WHERE %i = %s', $_GET['key'], $_GET['value'] ); // WHERE `a``b` = 'value' 676 * 677 * While changing to false will be fine for queries not using formatted/argnum placeholders, 678 * any remaining cases are most likely going to result in SQL errors (good, in a way): 679 * 680 * $wpdb->prepare( 'WHERE %1$s = "%2$-10s"', 'my_field', 'my_value' ); 681 * true = WHERE my_field = "my_value " 682 * false = WHERE 'my_field' = "'my_value '" 683 * 684 * But there may be some queries that result in an SQL Injection vulnerability: 685 * 686 * $wpdb->prepare( 'WHERE id = %1$s', $_GET['id'] ); // ?id=id 687 * 688 * So there may need to be a `_doing_it_wrong()` phase, after we know everyone can use 689 * identifier placeholders (%i), but before this feature is disabled or removed. 690 * 691 * @since 6.2.0 692 * @var bool 693 */ 694 private $allow_unsafe_unquoted_parameters = true; 695 696 /** 658 697 * Whether to use mysqli over mysql. Default false. 659 698 * … … 764 803 'table_charset', 765 804 'check_current_query', 805 'allow_unsafe_unquoted_parameters', 766 806 ); 767 807 if ( in_array( $name, $protected_members, true ) ) { … … 1364 1404 1365 1405 /** 1406 * Quotes an identifier for a MySQL database, e.g. table/field names. 1407 * 1408 * @since 6.2.0 1409 * 1410 * @param string $identifier Identifier to escape. 1411 * @return string Escaped identifier. 1412 */ 1413 public function quote_identifier( $identifier ) { 1414 return '`' . $this->_escape_identifier_value( $identifier ) . '`'; 1415 } 1416 1417 /** 1418 * Escapes an identifier value without adding the surrounding quotes. 1419 * 1420 * - Permitted characters in quoted identifiers include the full Unicode 1421 * Basic Multilingual Plane (BMP), except U+0000. 1422 * - To quote the identifier itself, you need to double the character, e.g. `a``b`. 1423 * 1424 * @since 6.2.0 1425 * 1426 * @link https://dev.mysql.com/doc/refman/8.0/en/identifiers.html 1427 * 1428 * @param string $identifier Identifier to escape. 1429 * @return string Escaped identifier. 1430 */ 1431 private function _escape_identifier_value( $identifier ) { 1432 return str_replace( '`', '``', $identifier ); 1433 } 1434 1435 /** 1366 1436 * Prepares a SQL query for safe execution. 1367 1437 * … … 1371 1441 * - %f (float) 1372 1442 * - %s (string) 1443 * - %i (identifier, e.g. table/field names) 1373 1444 * 1374 1445 * All placeholders MUST be left unquoted in the query string. A corresponding argument … … 1403 1474 * by updating the function signature. The second parameter was changed 1404 1475 * from `$args` to `...$args`. 1476 * @since 6.2.0 Added `%i` for identifiers, e.g. table or field names. 1477 * Check support via `wpdb::has_cap( 'identifier_placeholders' )`. 1478 * This preserves compatibility with sprintf(), as the C version uses 1479 * `%d` and `$i` as a signed integer, whereas PHP only supports `%d`. 1405 1480 * 1406 1481 * @link https://www.php.net/sprintf Description of syntax. … … 1434 1509 } 1435 1510 1436 // If args were passed as an array (as in vsprintf), move them up.1437 $passed_as_array = false;1438 if ( isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ) ) {1439 $passed_as_array = true;1440 $args = $args[0];1441 }1442 1443 foreach ( $args as $arg ) {1444 if ( ! is_scalar( $arg ) && ! is_null( $arg ) ) {1445 wp_load_translations_early();1446 _doing_it_wrong(1447 'wpdb::prepare',1448 sprintf(1449 /* translators: %s: Value type. */1450 __( 'Unsupported value type (%s).' ),1451 gettype( $arg )1452 ),1453 '4.8.2'1454 );1455 }1456 }1457 1458 1511 /* 1459 1512 * Specify the formatting allowed in a placeholder. The following are allowed: … … 1476 1529 $query = str_replace( "'%s'", '%s', $query ); // Strip any existing single quotes. 1477 1530 $query = str_replace( '"%s"', '%s', $query ); // Strip any existing double quotes. 1478 $query = preg_replace( '/(?<!%)%s/', "'%s'", $query ); // Quote the strings, avoiding escaped strings like %%s. 1479 1480 $query = preg_replace( "/(?<!%)(%($allowed_format)?f)/", '%\\2F', $query ); // Force floats to be locale-unaware. 1481 1482 $query = preg_replace( "/%(?:%|$|(?!($allowed_format)?[sdF]))/", '%%\\1', $query ); // Escape any unescaped percents. 1483 1484 // Count the number of valid placeholders in the query. 1485 $placeholders = preg_match_all( "/(^|[^%]|(%%)+)%($allowed_format)?[sdF]/", $query, $matches ); 1531 1532 // Escape any unescaped percents (i.e. anything unrecognised). 1533 $query = preg_replace( "/%(?:%|$|(?!($allowed_format)?[sdfFi]))/", '%%\\1', $query ); 1534 1535 // Extract placeholders from the query. 1536 $split_query = preg_split( "/(^|[^%]|(?:%%)+)(%(?:$allowed_format)?[sdfFi])/", $query, -1, PREG_SPLIT_DELIM_CAPTURE ); 1537 1538 $split_query_count = count( $split_query ); 1539 1540 /* 1541 * Split always returns with 1 value before the first placeholder (even with $query = "%s"), 1542 * then 3 additional values per placeholder. 1543 */ 1544 $placeholder_count = ( ( $split_query_count - 1 ) / 3 ); 1545 1546 // If args were passed as an array, as in vsprintf(), move them up. 1547 $passed_as_array = ( isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ) ); 1548 if ( $passed_as_array ) { 1549 $args = $args[0]; 1550 } 1551 1552 $new_query = ''; 1553 $key = 2; // Keys 0 and 1 in $split_query contain values before the first placeholder. 1554 $arg_id = 0; 1555 $arg_identifiers = array(); 1556 $arg_strings = array(); 1557 1558 while ( $key < $split_query_count ) { 1559 $placeholder = $split_query[ $key ]; 1560 1561 $format = substr( $placeholder, 1, -1 ); 1562 $type = substr( $placeholder, -1 ); 1563 1564 if ( 'f' === $type && true === $this->allow_unsafe_unquoted_parameters && str_ends_with( $split_query[ $key - 1 ], '%' ) ) { 1565 1566 /* 1567 * Before WP 6.2 the "force floats to be locale-unaware" RegEx didn't 1568 * convert "%%%f" to "%%%F" (note the uppercase F). 1569 * This was because it didn't check to see if the leading "%" was escaped. 1570 * And because the "Escape any unescaped percents" RegEx used "[sdF]" in its 1571 * negative lookahead assertion, when there was an odd number of "%", it added 1572 * an extra "%", to give the fully escaped "%%%%f" (not a placeholder). 1573 */ 1574 1575 $s = $split_query[ $key - 2 ] . $split_query[ $key - 1 ]; 1576 $k = 1; 1577 $l = strlen( $s ); 1578 while ( $k <= $l && '%' === $s[ $l - $k ] ) { 1579 $k++; 1580 } 1581 1582 $placeholder = '%' . ( $k % 2 ? '%' : '' ) . $format . $type; 1583 1584 --$placeholder_count; 1585 1586 } else { 1587 1588 // Force floats to be locale-unaware. 1589 if ( 'f' === $type ) { 1590 $type = 'F'; 1591 $placeholder = '%' . $format . $type; 1592 } 1593 1594 if ( 'i' === $type ) { 1595 $placeholder = '`%' . $format . 's`'; 1596 // Using a simple strpos() due to previous checking (e.g. $allowed_format). 1597 $argnum_pos = strpos( $format, '$' ); 1598 1599 if ( false !== $argnum_pos ) { 1600 // sprintf() argnum starts at 1, $arg_id from 0. 1601 $arg_identifiers[] = ( ( (int) substr( $format, 0, $argnum_pos ) ) - 1 ); 1602 } else { 1603 $arg_identifiers[] = $arg_id; 1604 } 1605 } elseif ( 'd' !== $type && 'F' !== $type ) { 1606 /* 1607 * i.e. ( 's' === $type ), where 'd' and 'F' keeps $placeholder unchanged, 1608 * and we ensure string escaping is used as a safe default (e.g. even if 'x'). 1609 */ 1610 $argnum_pos = strpos( $format, '$' ); 1611 1612 if ( false !== $argnum_pos ) { 1613 $arg_strings[] = ( ( (int) substr( $format, 0, $argnum_pos ) ) - 1 ); 1614 } else { 1615 $arg_strings[] = $arg_id; 1616 } 1617 1618 /* 1619 * Unquoted strings for backward compatibility (dangerous). 1620 * First, "numbered or formatted string placeholders (eg, %1$s, %5s)". 1621 * Second, if "%s" has a "%" before it, even if it's unrelated (e.g. "LIKE '%%%s%%'"). 1622 */ 1623 if ( true !== $this->allow_unsafe_unquoted_parameters || ( '' === $format && ! str_ends_with( $split_query[ $key - 1 ], '%' ) ) ) { 1624 $placeholder = "'%" . $format . "s'"; 1625 } 1626 } 1627 } 1628 1629 // Glue (-2), any leading characters (-1), then the new $placeholder. 1630 $new_query .= $split_query[ $key - 2 ] . $split_query[ $key - 1 ] . $placeholder; 1631 1632 $key += 3; 1633 $arg_id++; 1634 } 1635 1636 // Replace $query; and add remaining $query characters, or index 0 if there were no placeholders. 1637 $query = $new_query . $split_query[ $key - 2 ]; 1638 1639 $dual_use = array_intersect( $arg_identifiers, $arg_strings ); 1640 1641 if ( count( $dual_use ) > 0 ) { 1642 wp_load_translations_early(); 1643 1644 $used_placeholders = array(); 1645 1646 $key = 2; 1647 $arg_id = 0; 1648 // Parse again (only used when there is an error). 1649 while ( $key < $split_query_count ) { 1650 $placeholder = $split_query[ $key ]; 1651 1652 $format = substr( $placeholder, 1, -1 ); 1653 1654 $argnum_pos = strpos( $format, '$' ); 1655 1656 if ( false !== $argnum_pos ) { 1657 $arg_pos = ( ( (int) substr( $format, 0, $argnum_pos ) ) - 1 ); 1658 } else { 1659 $arg_pos = $arg_id; 1660 } 1661 1662 $used_placeholders[ $arg_pos ][] = $placeholder; 1663 1664 $key += 3; 1665 $arg_id++; 1666 } 1667 1668 $conflicts = array(); 1669 foreach ( $dual_use as $arg_pos ) { 1670 $conflicts[] = implode( ' and ', $used_placeholders[ $arg_pos ] ); 1671 } 1672 1673 _doing_it_wrong( 1674 'wpdb::prepare', 1675 sprintf( 1676 /* translators: %s: A list of placeholders found to be a problem. */ 1677 __( 'Arguments cannot be prepared as both an Identifier and Value. Found the following conflicts: %s' ), 1678 implode( ', ', $conflicts ) 1679 ), 1680 '6.2.0' 1681 ); 1682 1683 return; 1684 } 1486 1685 1487 1686 $args_count = count( $args ); 1488 1687 1489 if ( $args_count !== $placeholders ) { 1490 if ( 1 === $placeholders && $passed_as_array ) { 1491 // If the passed query only expected one argument, but the wrong number of arguments were sent as an array, bail. 1688 if ( $args_count !== $placeholder_count ) { 1689 if ( 1 === $placeholder_count && $passed_as_array ) { 1690 /* 1691 * If the passed query only expected one argument, 1692 * but the wrong number of arguments was sent as an array, bail. 1693 */ 1492 1694 wp_load_translations_early(); 1493 1695 _doing_it_wrong( … … 1510 1712 /* translators: 1: Number of placeholders, 2: Number of arguments passed. */ 1511 1713 __( 'The query does not contain the correct number of placeholders (%1$d) for the number of arguments passed (%2$d).' ), 1512 $placeholder s,1714 $placeholder_count, 1513 1715 $args_count 1514 1716 ), … … 1520 1722 * return an empty string to avoid a fatal error on PHP 8. 1521 1723 */ 1522 if ( $args_count < $placeholders ) { 1523 $max_numbered_placeholder = ! empty( $matches[3] ) ? max( array_map( 'intval', $matches[3] ) ) : 0; 1724 if ( $args_count < $placeholder_count ) { 1725 $max_numbered_placeholder = 0; 1726 1727 for ( $i = 2, $l = $split_query_count; $i < $l; $i += 3 ) { 1728 // Assume a leading number is for a numbered placeholder, e.g. '%3$s'. 1729 $argnum = (int) substr( $split_query[ $i ], 1 ); 1730 1731 if ( $max_numbered_placeholder < $argnum ) { 1732 $max_numbered_placeholder = $argnum; 1733 } 1734 } 1524 1735 1525 1736 if ( ! $max_numbered_placeholder || $args_count < $max_numbered_placeholder ) { … … 1530 1741 } 1531 1742 1532 array_walk( $args, array( $this, 'escape_by_ref' ) ); 1533 $query = vsprintf( $query, $args ); 1743 $args_escaped = array(); 1744 1745 foreach ( $args as $i => $value ) { 1746 if ( in_array( $i, $arg_identifiers, true ) ) { 1747 $args_escaped[] = $this->_escape_identifier_value( $value ); 1748 } elseif ( is_int( $value ) || is_float( $value ) ) { 1749 $args_escaped[] = $value; 1750 } else { 1751 if ( ! is_scalar( $value ) && ! is_null( $value ) ) { 1752 wp_load_translations_early(); 1753 _doing_it_wrong( 1754 'wpdb::prepare', 1755 sprintf( 1756 /* translators: %s: Value type. */ 1757 __( 'Unsupported value type (%s).' ), 1758 gettype( $value ) 1759 ), 1760 '4.8.2' 1761 ); 1762 1763 // Preserving old behavior, where values are escaped as strings. 1764 $value = ''; 1765 } 1766 1767 $args_escaped[] = $this->_real_escape( $value ); 1768 } 1769 } 1770 1771 $query = vsprintf( $query, $args_escaped ); 1534 1772 1535 1773 return $this->add_placeholder_escape( $query ); … … 3780 4018 * @since 4.1.0 Added support for the 'utf8mb4' feature. 3781 4019 * @since 4.6.0 Added support for the 'utf8mb4_520' feature. 4020 * @since 6.2.0 Added support for the 'identifier_placeholders' feature. 3782 4021 * 3783 4022 * @see wpdb::db_version() 3784 4023 * 3785 4024 * @param string $db_cap The feature to check for. Accepts 'collation', 'group_concat', 3786 * 'subqueries', 'set_charset', 'utf8mb4', or 'utf8mb4_520'. 4025 * 'subqueries', 'set_charset', 'utf8mb4', 'utf8mb4_520', 4026 * or 'identifier_placeholders'. 3787 4027 * @return bool True when the database feature is supported, false otherwise. 3788 4028 */ … … 3829 4069 case 'utf8mb4_520': // @since 4.6.0 3830 4070 return version_compare( $db_version, '5.6', '>=' ); 4071 case 'identifier_placeholders': // @since 6.2.0 4072 /* 4073 * As of WordPress 6.2, wpdb::prepare() supports identifiers via '%i', 4074 * e.g. table/field names. 4075 */ 4076 return true; 3831 4077 } 3832 4078
Note: See TracChangeset
for help on using the changeset viewer.