Make WordPress Core


Ignore:
Timestamp:
01/27/2023 06:47:53 PM (2 years ago)
Author:
davidbaumwald
Message:

Database: Add %i placeholder support to $wpdb->prepare to escape table and column names, take 2.

[53575] during the 6.1 cycle was reverted in [54734] to address issues around multiple % placeholders not being properly quoted as reported in #56933. Since then, this issue has been resolved and the underlying code improved significantly. Additionally, the unit tests have been expanded and the inline docs have been improved as well.

This change reintroduces %i placeholder support in $wpdb->prepare() to give extenders the ability to safely escape table and column names in database queries.

Follow-up to [53575] and [54734].

Props craigfrancis, jrf, xknown, costdev, ironprogrammer, SergeyBiryukov.
Fixes #52506.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/class-wpdb.php

    r54950 r55151  
    656656
    657657    /**
     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    /**
    658697     * Whether to use mysqli over mysql. Default false.
    659698     *
     
    764803            'table_charset',
    765804            'check_current_query',
     805            'allow_unsafe_unquoted_parameters',
    766806        );
    767807        if ( in_array( $name, $protected_members, true ) ) {
     
    13641404
    13651405    /**
     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    /**
    13661436     * Prepares a SQL query for safe execution.
    13671437     *
     
    13711441     * - %f (float)
    13721442     * - %s (string)
     1443     * - %i (identifier, e.g. table/field names)
    13731444     *
    13741445     * All placeholders MUST be left unquoted in the query string. A corresponding argument
     
    14031474     *              by updating the function signature. The second parameter was changed
    14041475     *              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`.
    14051480     *
    14061481     * @link https://www.php.net/sprintf Description of syntax.
     
    14341509        }
    14351510
    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 
    14581511        /*
    14591512         * Specify the formatting allowed in a placeholder. The following are allowed:
     
    14761529        $query = str_replace( "'%s'", '%s', $query ); // Strip any existing single quotes.
    14771530        $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        }
    14861685
    14871686        $args_count = count( $args );
    14881687
    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                 */
    14921694                wp_load_translations_early();
    14931695                _doing_it_wrong(
     
    15101712                        /* translators: 1: Number of placeholders, 2: Number of arguments passed. */
    15111713                        __( 'The query does not contain the correct number of placeholders (%1$d) for the number of arguments passed (%2$d).' ),
    1512                         $placeholders,
     1714                        $placeholder_count,
    15131715                        $args_count
    15141716                    ),
     
    15201722                 * return an empty string to avoid a fatal error on PHP 8.
    15211723                 */
    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                    }
    15241735
    15251736                    if ( ! $max_numbered_placeholder || $args_count < $max_numbered_placeholder ) {
     
    15301741        }
    15311742
    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 );
    15341772
    15351773        return $this->add_placeholder_escape( $query );
     
    37804018     * @since 4.1.0 Added support for the 'utf8mb4' feature.
    37814019     * @since 4.6.0 Added support for the 'utf8mb4_520' feature.
     4020     * @since 6.2.0 Added support for the 'identifier_placeholders' feature.
    37824021     *
    37834022     * @see wpdb::db_version()
    37844023     *
    37854024     * @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'.
    37874027     * @return bool True when the database feature is supported, false otherwise.
    37884028     */
     
    38294069            case 'utf8mb4_520': // @since 4.6.0
    38304070                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;
    38314077        }
    38324078
Note: See TracChangeset for help on using the changeset viewer.