Commit 08a7336bbd7 for woocommerce
commit 08a7336bbd7e823650ae0fe63373e591b933af7f
Author: daledupreez <dale@automattic.com>
Date: Fri Jun 26 21:30:43 2026 +0200
Prevent double-escaping in PlanRepository queries (#66060)
* Prevent double-escaping in PlanRepository queries
* Changelog
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/fix-double-escaping-in-plan-repository b/packages/php/woocommerce-subscriptions-engine/changelog/fix-double-escaping-in-plan-repository
new file mode 100644
index 00000000000..0f03bfb33bd
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/fix-double-escaping-in-plan-repository
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent double-escaping for incoming queries
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
index ac0fd17b28f..cc0e1821158 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
@@ -134,15 +134,20 @@ final class PlanRepository {
global $wpdb;
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
- $where = $this->build_where_clauses( $args );
$order = $this->build_order_clause( $args );
$limit = max( 1, self::coerce_int( $args['limit'] ?? null, 50 ) );
$offset = max( 0, self::coerce_int( $args['offset'] ?? null, 0 ) );
- $sql = "SELECT * FROM {$table}{$where} {$order} LIMIT %d OFFSET %d";
+ // phpcs:ignore Generic.Arrays.DisallowShortArraySyntax.Found
+ [
+ 'sql' => $where_sql,
+ 'params' => $where_params,
+ ] = $this->build_where_clause( $args );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
- $rows = $wpdb->get_results( $wpdb->prepare( $sql, $limit, $offset ), ARRAY_A );
+ $params = array( ...$where_params, $limit, $offset );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table}{$where_sql} {$order} LIMIT %d OFFSET %d", $params ), ARRAY_A );
if ( ! is_array( $rows ) ) {
return array();
}
@@ -169,10 +174,22 @@ final class PlanRepository {
global $wpdb;
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
- $where = $this->build_where_clauses( $args );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}{$where}" );
+ // phpcs:ignore Generic.Arrays.DisallowShortArraySyntax.Found
+ [
+ 'sql' => $where_sql,
+ 'params' => $where_params,
+ ] = $this->build_where_clause( $args );
+
+ if ( array() === $where_params ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $result = $wpdb->get_var( "SELECT COUNT(*) FROM {$table}{$where_sql}" );
+ } else {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ $result = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table}{$where_sql}", $where_params ) );
+ }
+
+ return (int) $result;
}
/**
@@ -307,23 +324,27 @@ final class PlanRepository {
}
/**
- * Build SQL WHERE clauses from supported query args.
+ * Build SQL WHERE clauses and params from supported query args.
*
* @param array<string, mixed> $args Query args.
+ * @return array{sql: string, params: array<int, mixed>}
*/
- private function build_where_clauses( array $args ): string {
+ private function build_where_clause( array $args ): array {
global $wpdb;
$clauses = array();
+ $params = array();
$status = self::coerce_string( $args['status'] ?? null );
if ( '' !== $status ) {
- $clauses[] = $wpdb->prepare( 'status = %s', $status );
+ $clauses[] = 'status = %s';
+ $params[] = $status;
}
if ( array_key_exists( 'extension_slug', $args ) ) {
if ( self::is_valid_extension_slug( $args['extension_slug'] ) ) {
- $clauses[] = $wpdb->prepare( 'extension_slug = %s', $args['extension_slug'] );
+ $clauses[] = 'extension_slug = %s';
+ $params[] = $args['extension_slug'];
} else {
$clauses[] = '0 = 1';
}
@@ -349,7 +370,8 @@ final class PlanRepository {
$are_extension_slugs_valid = true;
$extension_slugs = array_values( $valid_slugs );
- $clauses[] = $wpdb->prepare( 'extension_slug IN (' . implode( ',', array_fill( 0, count( $extension_slugs ), '%s' ) ) . ')', $extension_slugs );
+ $clauses[] = 'extension_slug IN (' . implode( ',', array_fill( 0, count( $extension_slugs ), '%s' ) ) . ')';
+ $params = array_merge( $params, $extension_slugs );
}
}
}
@@ -362,14 +384,22 @@ final class PlanRepository {
$search = self::coerce_string( $args['search'] ?? null );
if ( '' !== $search ) {
$like = '%' . $wpdb->esc_like( $search ) . '%';
- $clauses[] = $wpdb->prepare( '(name LIKE %s OR description LIKE %s)', $like, $like );
+ $clauses[] = '(name LIKE %s OR description LIKE %s)';
+ $params[] = $like;
+ $params[] = $like;
}
if ( empty( $clauses ) ) {
- return '';
+ return array(
+ 'sql' => '',
+ 'params' => array(),
+ );
}
- return ' WHERE ' . implode( ' AND ', $clauses );
+ return array(
+ 'sql' => ' WHERE ' . implode( ' AND ', $clauses ),
+ 'params' => $params,
+ );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
index 80e0e6ab42c..dd03e0f5c3f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
@@ -278,6 +278,50 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( array( $second_id, $archived_id, $first_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $ordered ) );
}
+ /**
+ * Search terms that previously looked like placeholders after LIKE wildcards.
+ *
+ * @return array<string, array<int, string>>
+ */
+ public function prepare_specifier_search_terms_provider(): array {
+ return array(
+ 'starts with s' => array( 'status-specifier-regression' ),
+ 'starts with d' => array( 'daily-specifier-regression' ),
+ 'starts with f' => array( 'fixed-specifier-regression' ),
+ 'starts with F' => array( 'Featured-specifier-regression' ),
+ 'starts with i' => array( 'intro-specifier-regression' ),
+ );
+ }
+
+ /**
+ * @dataProvider prepare_specifier_search_terms_provider
+ *
+ * @param string $search Search term.
+ */
+ public function test_query_search_terms_starting_with_prepare_specifiers( string $search ): void {
+ $group_id = $this->make_group();
+ $repo = new PlanRepository();
+
+ $this->make_plan( $repo, $group_id, 'Unrelated prepare regression plan', 'lite' );
+ $expected_id = $this->make_plan( $repo, $group_id, $search . ' plan', 'lite' );
+
+ $query_args = array(
+ 'extension_slug' => 'lite',
+ 'status' => Plan::STATUS_ACTIVE,
+ 'search' => $search,
+ 'orderby' => 'id',
+ 'order' => 'asc',
+ 'limit' => 10,
+ 'offset' => 0,
+ );
+ $plans = $repo->query( $query_args );
+
+ $this->assertCount( 1, $plans );
+ $this->assertSame( $expected_id, $plans[0]->get_id() );
+
+ $this->assertSame( 1, $repo->count( $query_args ) );
+ }
+
public function test_invalid_extension_scopes_do_not_return_unscoped_results(): void {
$group_id = $this->make_group();
$repo = new PlanRepository();