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/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.