Commit b7e62bd9085 for woocommerce
commit b7e62bd9085f7438d659ea26f3f93fbfe30c9241
Author: MILLER/F <fab@millerf.com>
Date: Tue Aug 11 10:02:06 2026 +0200
dev: add PHPCS sniff for unguarded %i identifier placeholder (#66380)
* dev: add PHPCS sniff for unguarded %i identifier placeholder
Adds a local PHPCS sniff (WooCommerceInternal.DB.IdentifierPlaceholder) that
flags the %i SQL identifier placeholder inside wpdb::prepare() calls unless a
wpdb::has_cap( 'identifier_placeholders' ) guard is present in the same
function. This prevents reintroducing %i, which some $wpdb drop-ins on
supported WordPress versions don't implement (silently producing malformed
queries).
The sniff lives under tests/Tools/phpcs/ (excluded from being linted as
product source) and is registered by path in phpcs.xml, scoped to src/ and
includes/. A fixture and standalone ruleset document and verify its behaviour.
Depends on the per-area interpolation PRs (#66372, #66374, #66375, #66376,
#66377, #66378) landing first; until then the sniff flags the existing %i
call sites. Supersedes #66262.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Apply suggestions from code review
Co-authored-by: MILLER/F <millerf@automattic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-identifier-placeholder-sniff b/plugins/woocommerce/changelog/add-identifier-placeholder-sniff
new file mode 100644
index 00000000000..5ed563668d4
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-identifier-placeholder-sniff
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add a PHPCS sniff that flags unguarded use of the `%i` SQL identifier placeholder in `wpdb::prepare()` calls, so trusted identifiers are interpolated directly (or `%i` is guarded with `wpdb::has_cap( 'identifier_placeholders' )`).
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 4b3d6a95997..1533bb857e3 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -26,6 +26,8 @@
<exclude-pattern>tests/e2e/test-plugins/blocks/</exclude-pattern>
<!-- Helper scripts loaded only during e2e tests. -->
<exclude-pattern>tests/e2e/bin/</exclude-pattern>
+ <!-- Custom PHPCS sniffs and their fixtures follow PHPCS naming/structure, not the WooCommerce ruleset. -->
+ <exclude-pattern>tests/Tools/phpcs/</exclude-pattern>
<!-- Only check PHP files. -->
<arg name="extensions" value="php" />
@@ -46,6 +48,13 @@
<!-- Rules -->
<rule ref="WooCommerce-Core" />
+ <!-- Local sniff: flag `%i` SQL identifier placeholders in wpdb::prepare() that aren't guarded by
+ wpdb::has_cap( 'identifier_placeholders' ). Trusted identifiers should be interpolated directly. -->
+ <rule ref="tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php">
+ <include-pattern>src/</include-pattern>
+ <include-pattern>includes/</include-pattern>
+ </rule>
+
<!-- The cart token selects which customer session loads; it must be read from one place only. -->
<rule ref="./bin/phpcs/WooCommerceStoreApi/Sniffs/StoreApi/CartTokenSourceSniff.php">
<!-- The canonical accessor, and tests that simulate raw request headers. -->
diff --git a/plugins/woocommerce/tests/Tools/phpcs/README.md b/plugins/woocommerce/tests/Tools/phpcs/README.md
new file mode 100644
index 00000000000..66c0052363f
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/README.md
@@ -0,0 +1,56 @@
+# Local PHPCS sniffs
+
+Custom PHP_CodeSniffer sniffs for WooCommerce Core that are not part of the shared
+[`woocommerce/woocommerce-sniffs`](https://github.com/woocommerce/woocommerce-sniffs)
+package. They live here (rather than in `WooCommerce-Core`) so they can ship and evolve
+with the codebase they guard.
+
+The sniffs are registered by relative path in `plugins/woocommerce/phpcs.xml`, and this
+directory is excluded from being linted as product source (sniff classes must be named
+`XxxSniff.php`, which conflicts with the WooCommerce filename convention).
+
+## `WooCommerceInternal.DB.IdentifierPlaceholder`
+
+Flags the `%i` SQL identifier placeholder inside a `wpdb::prepare()` call when it is **not**
+guarded by `wpdb::has_cap( 'identifier_placeholders' )` in the same function.
+
+WordPress 6.2 added `%i` to `wpdb::prepare()` for quoting table/column names, but a `$wpdb`
+drop-in can run on a supported WordPress version without implementing it (its
+`has_cap( 'identifier_placeholders' )` returns `false`). On such a layer `prepare()` treats
+`%i` as a literal and shifts the remaining positional arguments, silently producing malformed
+queries.
+
+**How to satisfy the sniff:**
+
+- Preferred — interpolate the trusted identifier directly into the query string:
+
+ ```php
+ $table = OrdersTableDataStore::get_orders_table_name();
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+ $wpdb->prepare( "SELECT id FROM {$table} WHERE customer_id = %d", $customer_id );
+ ```
+
+- When `%i` is genuinely required, guard it and provide a fallback:
+
+ ```php
+ if ( $wpdb->has_cap( 'identifier_placeholders' ) ) {
+ $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id );
+ }
+ ```
+
+The identifier MUST be a trusted, developer-provided value (a table or column name), never raw
+user input.
+
+### Verifying the sniff
+
+The fixture `WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc` documents the
+flagged and allowed cases. Run the sniff over it directly:
+
+```sh
+cd plugins/woocommerce
+bin/composer/phpcs/vendor/bin/phpcs -s \
+ --standard=tests/Tools/phpcs/WooCommerceInternal/ruleset.xml \
+ tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc
+```
+
+Only the two unguarded `%i` cases (cases 1 and 2) should be reported.
diff --git a/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php
new file mode 100644
index 00000000000..6f4ff038d73
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * IdentifierPlaceholderSniff.
+ *
+ * @package WooCommerce\Tests\Tools\PHPCS
+ */
+
+declare( strict_types=1 );
+
+namespace WooCommerceInternal\Sniffs\DB;
+
+use PHP_CodeSniffer\Files\File;
+use PHP_CodeSniffer\Sniffs\Sniff;
+use PHP_CodeSniffer\Util\Tokens;
+
+/**
+ * Flags use of the `%i` SQL identifier placeholder inside a `wpdb::prepare()` call
+ * that is not guarded by `wpdb::has_cap( 'identifier_placeholders' )`.
+ *
+ * WordPress 6.2 added `%i` to `wpdb::prepare()` for quoting table/column names, but a
+ * `$wpdb` drop-in can run on a supported WordPress version without implementing it (its
+ * `has_cap( 'identifier_placeholders' )` returns `false`). On such a layer `prepare()`
+ * treats `%i` as a literal and shifts the remaining positional arguments, silently
+ * producing malformed queries.
+ *
+ * Trusted identifiers (table/column names derived from `$wpdb->prefix`, `$wpdb->posts`,
+ * or a data store's `get_*_table_name()`) should be interpolated directly into the query
+ * string instead. When `%i` is genuinely required, guard it with a
+ * `wpdb::has_cap( 'identifier_placeholders' )` check in the same function so a fallback
+ * path can run on layers that lack `%i`.
+ */
+class IdentifierPlaceholderSniff implements Sniff {
+
+ /**
+ * Capability string that unlocks the `%i` placeholder.
+ *
+ * @var string
+ */
+ private const GUARD_CAPABILITY = 'identifier_placeholders';
+
+ /**
+ * Registers the tokens this sniff wants to listen for.
+ *
+ * @return array<int|string>
+ */
+ public function register(): array {
+ return array(
+ T_CONSTANT_ENCAPSED_STRING,
+ T_DOUBLE_QUOTED_STRING,
+ );
+ }
+
+ /**
+ * Processes a string token, flagging an unguarded `%i` placeholder in a prepare() call.
+ *
+ * @param File $phpcs_file The file being scanned.
+ * @param int $stack_ptr The position of the current token in the stack.
+ * @return void
+ */
+ public function process( File $phpcs_file, $stack_ptr ): void {
+ $tokens = $phpcs_file->getTokens();
+ $content = $tokens[ $stack_ptr ]['content'];
+
+ // Flag a `%i` identifier placeholder appearing in the string.
+ // - `(?<![:%])` excludes STR_TO_DATE minute specifiers (`%H:%i:%s`) and escaped `%%i`.
+ // - `(?![a-zA-Z0-9_])` excludes longer tokens such as `%input`.
+ if ( ! preg_match( '/(?<![:%])%i(?![a-zA-Z0-9_])/', $content ) ) {
+ return;
+ }
+
+ if ( ! $this->is_within_prepare_call( $phpcs_file, $stack_ptr ) ) {
+ return;
+ }
+
+ if ( $this->has_identifier_placeholder_guard( $phpcs_file, $stack_ptr ) ) {
+ return;
+ }
+
+ $phpcs_file->addError(
+ 'The %%i identifier placeholder is not implemented by every $wpdb drop-in on supported WordPress versions and can silently produce malformed queries. Interpolate a trusted identifier directly into the query string, or guard the %%i usage with wpdb::has_cap( \'identifier_placeholders\' ).',
+ $stack_ptr,
+ 'Unguarded'
+ );
+ }
+
+ /**
+ * Determines whether the given string token is an argument to a `prepare()` call.
+ *
+ * @param File $phpcs_file The file being scanned.
+ * @param int $stack_ptr The position of the string token.
+ * @return bool
+ */
+ private function is_within_prepare_call( File $phpcs_file, int $stack_ptr ): bool {
+ $tokens = $phpcs_file->getTokens();
+
+ if ( empty( $tokens[ $stack_ptr ]['nested_parenthesis'] ) ) {
+ return false;
+ }
+
+ foreach ( array_keys( $tokens[ $stack_ptr ]['nested_parenthesis'] ) as $open_paren ) {
+ $before = $phpcs_file->findPrevious( Tokens::$emptyTokens, $open_paren - 1, null, true );
+
+ if ( false !== $before
+ && T_STRING === $tokens[ $before ]['code']
+ && 'prepare' === strtolower( $tokens[ $before ]['content'] )
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines whether the enclosing function guards `%i` usage with a
+ * `has_cap( 'identifier_placeholders' )` check.
+ *
+ * @param File $phpcs_file The file being scanned.
+ * @param int $stack_ptr The position of the string token.
+ * @return bool
+ */
+ private function has_identifier_placeholder_guard( File $phpcs_file, int $stack_ptr ): bool {
+ $tokens = $phpcs_file->getTokens();
+
+ $start = 0;
+ $end = $phpcs_file->numTokens - 1;
+
+ // Narrow the search to the innermost enclosing function/closure when there is one.
+ if ( ! empty( $tokens[ $stack_ptr ]['conditions'] ) ) {
+ foreach ( array_reverse( $tokens[ $stack_ptr ]['conditions'], true ) as $ptr => $code ) {
+ if ( ( T_FUNCTION === $code || T_CLOSURE === $code )
+ && isset( $tokens[ $ptr ]['scope_opener'], $tokens[ $ptr ]['scope_closer'] )
+ ) {
+ $start = $tokens[ $ptr ]['scope_opener'];
+ $end = $tokens[ $ptr ]['scope_closer'];
+ break;
+ }
+ }
+ }
+
+ for ( $i = $start; $i <= $end; $i++ ) {
+ if ( T_STRING !== $tokens[ $i ]['code'] || 'has_cap' !== strtolower( $tokens[ $i ]['content'] ) ) {
+ continue;
+ }
+
+ $open_paren = $phpcs_file->findNext( Tokens::$emptyTokens, $i + 1, null, true );
+ if ( false === $open_paren
+ || T_OPEN_PARENTHESIS !== $tokens[ $open_paren ]['code']
+ || empty( $tokens[ $open_paren ]['parenthesis_closer'] )
+ ) {
+ continue;
+ }
+
+ $close_paren = $tokens[ $open_paren ]['parenthesis_closer'];
+ for ( $j = $open_paren + 1; $j < $close_paren; $j++ ) {
+ if ( ( T_CONSTANT_ENCAPSED_STRING === $tokens[ $j ]['code'] || T_DOUBLE_QUOTED_STRING === $tokens[ $j ]['code'] )
+ && false !== strpos( $tokens[ $j ]['content'], self::GUARD_CAPABILITY )
+ ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc
new file mode 100644
index 00000000000..9be67b75da8
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Fixture for IdentifierPlaceholderSniff.
+ *
+ * Each numbered case documents whether the sniff should flag it.
+ *
+ * @package WooCommerce\Tests\Tools\PHPCS
+ */
+
+// Case 1: unguarded %i in prepare() — FLAGGED.
+function case_unguarded_single_line() {
+ global $wpdb;
+ return $wpdb->get_var( $wpdb->prepare( 'SELECT session_value FROM %i WHERE session_key = %s', $table, $key ) );
+}
+
+// Case 2: unguarded %i in a multi-line prepare() — FLAGGED.
+function case_unguarded_multi_line() {
+ global $wpdb;
+ return $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT id
+ FROM %i
+ WHERE type = %s",
+ $table,
+ $type
+ )
+ );
+}
+
+// Case 3: %i guarded by has_cap( 'identifier_placeholders' ) in the same function — NOT flagged.
+function case_guarded() {
+ global $wpdb;
+ if ( $wpdb->has_cap( 'identifier_placeholders' ) ) {
+ return $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id );
+ }
+ return $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id );
+}
+
+// Case 4: direct interpolation, no %i — NOT flagged.
+function case_interpolated() {
+ global $wpdb;
+ return $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id );
+}
+
+// Case 5: %%i escaped literal and %H:%i:%s time specifier — NOT flagged.
+function case_time_and_escape() {
+ global $wpdb;
+ $a = $wpdb->prepare( "SELECT DATE_FORMAT( created, '%%Y-%%m-%%d %%H:%%i:%%s' ) FROM {$table} WHERE id = %d", $id );
+ $b = $wpdb->prepare( 'SELECT a %%i b FROM ' . $table, $id );
+ return array( $a, $b );
+}
+
+// Case 6: %i in a non-prepare() query string — NOT flagged (only prepare() is targeted).
+function case_not_prepare() {
+ global $wpdb;
+ return $wpdb->get_var( 'SELECT progress %i FROM somewhere' );
+}
diff --git a/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/ruleset.xml b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/ruleset.xml
new file mode 100644
index 00000000000..5af186b2120
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/ruleset.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<ruleset name="WooCommerceInternal">
+ <description>Local WooCommerce Core PHPCS sniffs. Used standalone to verify the sniffs against their fixtures; the sniffs are wired into the main ruleset from plugins/woocommerce/phpcs.xml.</description>
+
+ <rule ref="./Sniffs/DB/IdentifierPlaceholderSniff.php" />
+</ruleset>