Make WordPress Core

Changeset 55151


Ignore:
Timestamp:
01/27/2023 06:47:53 PM (22 months 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.

Location:
trunk
Files:
2 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
  • trunk/tests/phpunit/tests/db.php

    r54733 r55151  
    495495        $this->assertTrue( $wpdb->has_cap( 'group_concat' ) );
    496496        $this->assertTrue( $wpdb->has_cap( 'subqueries' ) );
     497        $this->assertTrue( $wpdb->has_cap( 'identifier_placeholders' ) );
    497498        $this->assertTrue( $wpdb->has_cap( 'COLLATION' ) );
    498499        $this->assertTrue( $wpdb->has_cap( 'GROUP_CONCAT' ) );
    499500        $this->assertTrue( $wpdb->has_cap( 'SUBQUERIES' ) );
     501        $this->assertTrue( $wpdb->has_cap( 'IDENTIFIER_PLACEHOLDERS' ) );
    500502        $this->assertSame(
    501503            version_compare( $wpdb->db_version(), '5.0.7', '>=' ),
     
    15161518        global $wpdb;
    15171519
    1518         if ( $incorrect_usage ) {
     1520        if ( is_string( $incorrect_usage ) || true === $incorrect_usage ) {
    15191521            $this->setExpectedIncorrectUsage( 'wpdb::prepare' );
    15201522        }
     
    15261528        // phpcs:ignore WordPress.DB.PreparedSQL
    15271529        $sql = $wpdb->prepare( $sql, ...$values );
    1528         $this->assertSame( $expected, $sql );
     1530        $this->assertSame( $expected, $sql, 'The expected SQL does not match' );
     1531
     1532        if ( is_string( $incorrect_usage ) && array_key_exists( 'wpdb::prepare', $this->caught_doing_it_wrong ) ) {
     1533            $this->assertStringContainsString( $incorrect_usage, $this->caught_doing_it_wrong['wpdb::prepare'], 'The "_doing_it_wrong" message does not match' );
     1534        }
    15291535    }
    15301536
     
    15351541        global $wpdb;
    15361542
    1537         if ( $incorrect_usage ) {
     1543        if ( is_string( $incorrect_usage ) || true === $incorrect_usage ) {
    15381544            $this->setExpectedIncorrectUsage( 'wpdb::prepare' );
    15391545        }
     
    15451551        // phpcs:ignore WordPress.DB.PreparedSQL
    15461552        $sql = $wpdb->prepare( $sql, $values );
    1547         $this->assertSame( $expected, $sql );
     1553        $this->assertSame( $expected, $sql, 'The expected SQL does not match' );
     1554
     1555        if ( is_string( $incorrect_usage ) && array_key_exists( 'wpdb::prepare', $this->caught_doing_it_wrong ) ) {
     1556            $this->assertStringContainsString( $incorrect_usage, $this->caught_doing_it_wrong['wpdb::prepare'], 'The "_doing_it_wrong" message does not match' );
     1557        }
    15481558    }
    15491559
     
    17041714                "'{$placeholder_escape}'{$placeholder_escape}s",
    17051715            ),
    1706             array(
    1707                 "'%'%%s%s",
    1708                 'hello',
    1709                 false,
    1710                 "'{$placeholder_escape}'{$placeholder_escape}s'hello'",
    1711             ),
    1712             array(
    1713                 "'%'%%s %s",
    1714                 'hello',
    1715                 false,
    1716                 "'{$placeholder_escape}'{$placeholder_escape}s 'hello'",
    1717             ),
     1716
    17181717            /*
    17191718             * @ticket 56933.
    17201719             * When preparing a '%%%s%%', test that the inserted value
    1721              * is not wrapped in single quotes between the 2 hex values.
     1720             * is not wrapped in single quotes between the 2 "%".
    17221721             */
     1722            array(
     1723                '%%s %d',
     1724                1,
     1725                false,
     1726                "{$placeholder_escape}s 1",
     1727            ),
     1728            array(
     1729                '%%%s',
     1730                'hello',
     1731                false,
     1732                "{$placeholder_escape}hello",
     1733            ),
     1734            array(
     1735                '%%%%s',
     1736                'hello',
     1737                false,
     1738                "{$placeholder_escape}{$placeholder_escape}s",
     1739            ),
     1740            array(
     1741                '%%%%%s',
     1742                'hello',
     1743                false,
     1744                "{$placeholder_escape}{$placeholder_escape}hello",
     1745            ),
    17231746            array(
    17241747                '%%%s%%',
     
    17281751            ),
    17291752            array(
     1753                "'%'%%s%s",
     1754                'hello',
     1755                false,
     1756                "'{$placeholder_escape}'{$placeholder_escape}s'hello'",
     1757            ),
     1758            array(
     1759                "'%'%%s %s",
     1760                'hello',
     1761                false,
     1762                "'{$placeholder_escape}'{$placeholder_escape}s 'hello'",
     1763            ),
     1764            array(
    17301765                "'%-'#5s' '%'#-+-5s'",
    17311766                array( 'hello', 'foo' ),
     
    17331768                "'hello' 'foo##'",
    17341769            ),
     1770
     1771            /*
     1772             * Before WP 6.2 the "force floats to be locale-unaware" RegEx didn't
     1773             * convert "%%%f" to "%%%F" (note the uppercase F).
     1774             * This was because it didn't check to see if the leading "%" was escaped.
     1775             * And because the "Escape any unescaped percents" RegEx used "[sdF]" in its
     1776             * negative lookahead assertion, when there was an odd number of "%", it added
     1777             * an extra "%", to give the fully escaped "%%%%f" (not a placeholder).
     1778             */
     1779            array(
     1780                '%f OR id = %d',
     1781                array( 3, 5 ),
     1782                false,
     1783                '3.000000 OR id = 5',
     1784            ),
     1785            array(
     1786                '%%f OR id = %d',
     1787                array( 5 ),
     1788                false,
     1789                "{$placeholder_escape}f OR id = 5",
     1790            ),
     1791            array(
     1792                '%%%f OR id = %d',
     1793                array( 5 ),
     1794                false,
     1795                "{$placeholder_escape}{$placeholder_escape}f OR id = 5",
     1796            ),
     1797            array(
     1798                '%%%%f OR id = %d',
     1799                array( 5 ),
     1800                false,
     1801                "{$placeholder_escape}{$placeholder_escape}f OR id = 5",
     1802            ),
     1803            array(
     1804                "WHERE id = %d AND content LIKE '%.4f'",
     1805                array( 1, 2 ),
     1806                false,
     1807                "WHERE id = 1 AND content LIKE '2.0000'",
     1808            ),
     1809            array(
     1810                "WHERE id = %d AND content LIKE '%%.4f'",
     1811                array( 1 ),
     1812                false,
     1813                "WHERE id = 1 AND content LIKE '{$placeholder_escape}.4f'",
     1814            ),
     1815            array(
     1816                "WHERE id = %d AND content LIKE '%%%.4f'",
     1817                array( 1 ),
     1818                false,
     1819                "WHERE id = 1 AND content LIKE '{$placeholder_escape}{$placeholder_escape}.4f'",
     1820            ),
     1821            array(
     1822                "WHERE id = %d AND content LIKE '%%%%.4f'",
     1823                array( 1 ),
     1824                false,
     1825                "WHERE id = 1 AND content LIKE '{$placeholder_escape}{$placeholder_escape}.4f'",
     1826            ),
     1827            array(
     1828                "WHERE id = %d AND content LIKE '%%%%%.4f'",
     1829                array( 1 ),
     1830                false,
     1831                "WHERE id = 1 AND content LIKE '{$placeholder_escape}{$placeholder_escape}{$placeholder_escape}.4f'",
     1832            ),
     1833            array(
     1834                '%.4f',
     1835                array( 1 ),
     1836                false,
     1837                '1.0000',
     1838            ),
     1839            array(
     1840                '%.4f OR id = %d',
     1841                array( 1, 5 ),
     1842                false,
     1843                '1.0000 OR id = 5',
     1844            ),
     1845            array(
     1846                '%%.4f OR id = %d',
     1847                array( 5 ),
     1848                false,
     1849                "{$placeholder_escape}.4f OR id = 5",
     1850            ),
     1851            array(
     1852                '%%%.4f OR id = %d',
     1853                array( 5 ),
     1854                false,
     1855                "{$placeholder_escape}{$placeholder_escape}.4f OR id = 5",
     1856            ),
     1857            array(
     1858                '%%%%.4f OR id = %d',
     1859                array( 5 ),
     1860                false,
     1861                "{$placeholder_escape}{$placeholder_escape}.4f OR id = 5",
     1862            ),
     1863            array(
     1864                '%%%%%.4f OR id = %d',
     1865                array( 5 ),
     1866                false,
     1867                "{$placeholder_escape}{$placeholder_escape}{$placeholder_escape}.4f OR id = 5",
     1868            ),
     1869
     1870            /*
     1871             * @ticket 52506.
     1872             * Adding an escape method for Identifiers (e.g. table/field names).
     1873             */
     1874            array(
     1875                'SELECT * FROM %i WHERE %i = %d;',
     1876                array( 'my_table', 'my_field', 321 ),
     1877                false,
     1878                'SELECT * FROM `my_table` WHERE `my_field` = 321;',
     1879            ),
     1880            array(
     1881                'WHERE %i = %d;',
     1882                array( 'evil_`_field', 321 ),
     1883                false,
     1884                'WHERE `evil_``_field` = 321;', // To quote the identifier itself, then you need to double the character, e.g. `a``b`.
     1885            ),
     1886            array(
     1887                'WHERE %i = %d;',
     1888                array( 'evil_````````_field', 321 ),
     1889                false,
     1890                'WHERE `evil_````````````````_field` = 321;',
     1891            ),
     1892            array(
     1893                'WHERE %i = %d;',
     1894                array( '``evil_field``', 321 ),
     1895                false,
     1896                'WHERE `````evil_field````` = 321;',
     1897            ),
     1898            array(
     1899                'WHERE %i = %d;',
     1900                array( 'evil\'field', 321 ),
     1901                false,
     1902                'WHERE `evil\'field` = 321;',
     1903            ),
     1904            array(
     1905                'WHERE %i = %d;',
     1906                array( 'evil_\``_field', 321 ),
     1907                false,
     1908                'WHERE `evil_\````_field` = 321;',
     1909            ),
     1910            array(
     1911                'WHERE %i = %d;',
     1912                array( 'evil_%s_field', 321 ),
     1913                false,
     1914                "WHERE `evil_{$placeholder_escape}s_field` = 321;",
     1915            ),
     1916            array(
     1917                'WHERE %i = %d;',
     1918                array( 'value`', 321 ),
     1919                false,
     1920                'WHERE `value``` = 321;',
     1921            ),
     1922            array(
     1923                'WHERE `%i = %d;',
     1924                array( ' AND evil_value', 321 ),
     1925                false,
     1926                'WHERE `` AND evil_value` = 321;', // Won't run (SQL parse error: "Unclosed quote").
     1927            ),
     1928            array(
     1929                'WHERE %i` = %d;',
     1930                array( 'evil_value -- ', 321 ),
     1931                false,
     1932                'WHERE `evil_value -- `` = 321;', // Won't run (SQL parse error: "Unclosed quote").
     1933            ),
     1934            array(
     1935                'WHERE `%i`` = %d;',
     1936                array( ' AND true -- ', 321 ),
     1937                false,
     1938                'WHERE `` AND true -- ``` = 321;', // Won't run (Unknown column '').
     1939            ),
     1940            array(
     1941                'WHERE ``%i` = %d;',
     1942                array( ' AND true -- ', 321 ),
     1943                false,
     1944                'WHERE ``` AND true -- `` = 321;', // Won't run (SQL parse error: "Unclosed quote").
     1945            ),
     1946            array(
     1947                'WHERE %2$i = %1$d;',
     1948                array( '1', 'two' ),
     1949                false,
     1950                'WHERE `two` = 1;',
     1951            ),
     1952            array(
     1953                'WHERE \'%i\' = 1 AND "%i" = 2 AND `%i` = 3 AND ``%i`` = 4 AND %15i = 5',
     1954                array( 'my_field1', 'my_field2', 'my_field3', 'my_field4', 'my_field5' ),
     1955                false,
     1956                'WHERE \'`my_field1`\' = 1 AND "`my_field2`" = 2 AND ``my_field3`` = 3 AND ```my_field4``` = 4 AND `      my_field5` = 5', // Does not remove any existing quotes, always adds it's own (safer).
     1957            ),
     1958            array(
     1959                'WHERE id = %d AND %i LIKE %2$s LIMIT 1',
     1960                array( 123, 'field -- ', false ),
     1961                'Arguments cannot be prepared as both an Identifier and Value. Found the following conflicts: %i and %2$s',
     1962                null, // Should be rejected, otherwise the `%1$s` could use Identifier escaping, e.g. 'WHERE `field -- ` LIKE field --  LIMIT 1' (thanks @vortfu).
     1963            ),
     1964            array(
     1965                'WHERE %i LIKE %s LIMIT 1',
     1966                array( "field' -- ", "field' -- " ),
     1967                false,
     1968                "WHERE `field' -- ` LIKE 'field\' -- ' LIMIT 1", // In contrast to the above, Identifier vs String escaping is used.
     1969            ),
     1970            array(
     1971                'WHERE %2$i IN ( %s , %s ) LIMIT 1',
     1972                array( 'a', 'b' ),
     1973                'Arguments cannot be prepared as both an Identifier and Value. Found the following conflicts: %2$i and %s',
     1974                null,
     1975            ),
     1976            array(
     1977                'WHERE %1$i = %1$s',
     1978                array( 'a', 'b' ),
     1979                'Arguments cannot be prepared as both an Identifier and Value. Found the following conflicts: %1$i and %1$s',
     1980                null,
     1981            ),
     1982            array(
     1983                'WHERE %1$i = %1$s OR %2$i = %2$s',
     1984                array( 'a', 'b' ),
     1985                'Arguments cannot be prepared as both an Identifier and Value. Found the following conflicts: %1$i and %1$s, %2$i and %2$s',
     1986                null,
     1987            ),
     1988            array(
     1989                'WHERE %1$i = %1$s OR %2$i = %1$s',
     1990                array( 'a', 'b' ),
     1991                'Arguments cannot be prepared as both an Identifier and Value. Found the following conflicts: %1$i and %1$s and %1$s',
     1992                null,
     1993            ),
     1994        );
     1995    }
     1996
     1997    /**
     1998     * The wpdb->allow_unsafe_unquoted_parameters is true (for now), purely for backwards compatibility reasons.
     1999     *
     2000     * @ticket 52506
     2001     *
     2002     * @dataProvider data_prepare_should_respect_the_allow_unsafe_unquoted_parameters_property
     2003     *
     2004     * @covers wpdb::prepare
     2005     *
     2006     * @param bool   $allow    Whether to allow unsafe unquoted parameters.
     2007     * @param string $sql      The SQL to prepare.
     2008     * @param array  $values   The values for prepare.
     2009     * @param string $expected The expected prepared parameters.
     2010     */
     2011    public function test_prepare_should_respect_the_allow_unsafe_unquoted_parameters_property( $allow, $sql, $values, $expected ) {
     2012        global $wpdb;
     2013
     2014        $default = $wpdb->allow_unsafe_unquoted_parameters;
     2015
     2016        $property = new ReflectionProperty( $wpdb, 'allow_unsafe_unquoted_parameters' );
     2017        $property->setAccessible( true );
     2018        $property->setValue( $wpdb, $allow );
     2019
     2020        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     2021        $actual = $wpdb->prepare( $sql, $values );
     2022
     2023        // Reset.
     2024        $property->setValue( $wpdb, $default );
     2025        $property->setAccessible( false );
     2026
     2027        $this->assertSame( $expected, $actual );
     2028    }
     2029
     2030    /**
     2031     * Data provider for test_prepare_should_respect_the_allow_unsafe_unquoted_parameters_property().
     2032     *
     2033     * @return array[]
     2034     */
     2035    public function data_prepare_should_respect_the_allow_unsafe_unquoted_parameters_property() {
     2036        global $wpdb;
     2037
     2038        $placeholder_escape = $wpdb->placeholder_escape();
     2039
     2040        return array(
     2041
     2042            'numbered-true-1'  => array(
     2043                'allow'    => true,
     2044                'sql'      => 'WHERE (%i = %s) OR (%3$i = %4$s)',
     2045                'values'   => array( 'field_a', 'string_a', 'field_b', 'string_b' ),
     2046                'expected' => 'WHERE (`field_a` = \'string_a\') OR (`field_b` = string_b)',
     2047            ),
     2048            'numbered-false-1' => array(
     2049                'allow'    => false,
     2050                'sql'      => 'WHERE (%i = %s) OR (%3$i = %4$s)',
     2051                'values'   => array( 'field_a', 'string_a', 'field_b', 'string_b' ),
     2052                'expected' => 'WHERE (`field_a` = \'string_a\') OR (`field_b` = \'string_b\')',
     2053            ),
     2054            'numbered-true-2'  => array(
     2055                'allow'    => true,
     2056                'sql'      => 'WHERE (%i = %s) OR (%3$i = %4$s)',
     2057                'values'   => array( 'field_a', 'string_a', 'field_b', '0 OR EvilSQL' ),
     2058                'expected' => 'WHERE (`field_a` = \'string_a\') OR (`field_b` = 0 OR EvilSQL)',
     2059            ),
     2060            'numbered-false-2' => array(
     2061                'allow'    => false,
     2062                'sql'      => 'WHERE (%i = %s) OR (%3$i = %4$s)',
     2063                'values'   => array( 'field_a', 'string_a', 'field_b', '0 OR EvilSQL' ),
     2064                'expected' => 'WHERE (`field_a` = \'string_a\') OR (`field_b` = \'0 OR EvilSQL\')',
     2065            ),
     2066
     2067            'format-true-1'    => array(
     2068                'allow'    => true,
     2069                'sql'      => 'WHERE (%10i = %10s)',
     2070                'values'   => array( 'field_a', 'string_a' ),
     2071                'expected' => 'WHERE (`   field_a` =   string_a)',
     2072            ),
     2073            'format-false-1'   => array(
     2074                'allow'    => false,
     2075                'sql'      => 'WHERE (%10i = %10s)',
     2076                'values'   => array( 'field_a', 'string_a' ),
     2077                'expected' => 'WHERE (`   field_a` = \'  string_a\')',
     2078            ),
     2079            'format-true-2'    => array(
     2080                'allow'    => true,
     2081                'sql'      => 'WHERE (%10i = %10s)',
     2082                'values'   => array( 'field_a', '0 OR EvilSQL' ),
     2083                'expected' => 'WHERE (`   field_a` = 0 OR EvilSQL)',
     2084            ),
     2085            'format-false-2'   => array(
     2086                'allow'    => false,
     2087                'sql'      => 'WHERE (%10i = %10s)',
     2088                'values'   => array( 'field_a', '0 OR EvilSQL' ),
     2089                'expected' => 'WHERE (`   field_a` = \'0 OR EvilSQL\')',
     2090            ),
     2091
     2092            'escaped-true-1'   => array(
     2093                'allow'    => true,
     2094                'sql'      => 'SELECT 9%%%s',
     2095                'values'   => array( '7' ),
     2096                'expected' => "SELECT 9{$placeholder_escape}7", // SELECT 9%7.
     2097            ),
     2098            'escaped-false-1'  => array(
     2099                'allow'    => false,
     2100                'sql'      => 'SELECT 9%%%s',
     2101                'values'   => array( '7' ),
     2102                'expected' => "SELECT 9{$placeholder_escape}'7'", // SELECT 9%'7'.
     2103            ),
     2104            'escaped-true-2'   => array(
     2105                'allow'    => true,
     2106                'sql'      => 'SELECT 9%%%s',
     2107                'values'   => array( '7 OR EvilSQL' ),
     2108                'expected' => "SELECT 9{$placeholder_escape}7 OR EvilSQL", // SELECT 9%7 OR EvilSQL.
     2109            ),
     2110            'escaped-false-2'  => array(
     2111                'allow'    => false,
     2112                'sql'      => 'SELECT 9%%%s',
     2113                'values'   => array( '7 OR EvilSQL' ),
     2114                'expected' => "SELECT 9{$placeholder_escape}'7 OR EvilSQL'", // SELECT 9%'7 OR EvilSQL'.
     2115            ),
     2116
    17352117        );
    17362118    }
Note: See TracChangeset for help on using the changeset viewer.