Commit eb46078985e for woocommerce

commit eb46078985e9bf5a8b6c2f3653ef371259713a50
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Mon Aug 3 13:15:19 2026 -0400

    Match Blueprint restricted options regardless of key spelling (#67348)

    * Blueprint: normalise option keys before the restricted-options check

    The SetSiteOptions importer compared the imported key against
    RESTRICTED_OPTIONS with a strict in_array(), which is case-sensitive.
    WordPress option lookups are not: they resolve through the options
    table's collation (utf8mb4_unicode_520_ci by default), which folds both
    case and accents, and WordPress trims option names before using them.

    A key such as 'wp_USER_roles', ' wp_user_roles' or 'wp_usér_roles'
    therefore passed the check and then wrote to the genuine 'wp_user_roles'
    row, letting an imported blueprint grant administrator capabilities to a
    role it should not have been able to touch.

    The check now runs in two layers: the key is normalised (trimmed and
    lowercased) before comparison, and the key is additionally resolved
    through the database so equivalences that string normalisation cannot
    model — accent folding in particular — are caught by the collation that
    actually decides which row is written.

diff --git a/packages/php/blueprint/changelog/woo6-96-fix-restricted-options-case-bypass b/packages/php/blueprint/changelog/woo6-96-fix-restricted-options-case-bypass
new file mode 100644
index 00000000000..10c50adc292
--- /dev/null
+++ b/packages/php/blueprint/changelog/woo6-96-fix-restricted-options-case-bypass
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Match Blueprint's restricted site options regardless of the casing, padding or accents used in the imported key.
diff --git a/packages/php/blueprint/src/Importers/ImportSetSiteOptions.php b/packages/php/blueprint/src/Importers/ImportSetSiteOptions.php
index 641aea5fe41..e59b12cd55f 100644
--- a/packages/php/blueprint/src/Importers/ImportSetSiteOptions.php
+++ b/packages/php/blueprint/src/Importers/ImportSetSiteOptions.php
@@ -20,6 +20,9 @@ class ImportSetSiteOptions implements StepProcessor {
 	/**
 	 * List of WordPress options that should not be modified.
 	 *
+	 * Entries must be lowercase so normalised keys can be compared directly before
+	 * the database-collation check.
+	 *
 	 * @var array<string>
 	 */
 	private const RESTRICTED_OPTIONS = array(
@@ -46,10 +49,11 @@ class ImportSetSiteOptions implements StepProcessor {
 	 * @return StepProcessorResult
 	 */
 	public function process( $schema ): StepProcessorResult {
-		$result = StepProcessorResult::success( SetSiteOptions::get_step_name() );
+		$result                  = StepProcessorResult::success( SetSiteOptions::get_step_name() );
+		$restricted_option_names = $this->get_restricted_option_names( array_keys( (array) $schema->options ) );
 		foreach ( $schema->options as $key => $value ) {
 			// Skip if the option should not be modified.
-			if ( in_array( $key, self::RESTRICTED_OPTIONS, true ) ) {
+			if ( isset( $restricted_option_names[ (string) $key ] ) ) {
 				$result->add_warn( "Cannot modify '{$key}' option: Modifying is restricted for this key." );
 				continue;
 			}
@@ -76,6 +80,106 @@ class ImportSetSiteOptions implements StepProcessor {
 		return $result;
 	}

+	/**
+	 * Find restricted option names among imported keys.
+	 *
+	 * @param array<int|string> $keys Option keys from the blueprint.
+	 *
+	 * @return array<int|string, bool> Restricted option names as keys.
+	 */
+	private function get_restricted_option_names( array $keys ): array {
+		$restricted_option_names = array();
+		$database_candidates     = array();
+
+		foreach ( $keys as $key ) {
+			$key = (string) $key;
+			if ( in_array( self::normalize_option_name( $key ), self::RESTRICTED_OPTIONS, true ) ) {
+				$restricted_option_names[ $key ] = true;
+				continue;
+			}
+
+			$database_candidates[] = array(
+				'original' => $key,
+				'trimmed'  => trim( $key ),
+			);
+		}
+
+		$candidate_names = array_column( $database_candidates, 'trimmed' );
+		foreach ( $this->get_collation_restricted_option_indexes( $candidate_names ) as $candidate_index ) {
+			if ( ! isset( $database_candidates[ $candidate_index ] ) ) {
+				continue;
+			}
+			$restricted_option_names[ $database_candidates[ $candidate_index ]['original'] ] = true;
+		}
+
+		return $restricted_option_names;
+	}
+
+	/**
+	 * Normalise an option name for comparison against RESTRICTED_OPTIONS.
+	 *
+	 * Mirrors the trimming WordPress applies to option names, and lowercases so the
+	 * comparison matches the case-insensitive collation the database uses.
+	 *
+	 * @param string $key The option key to normalise.
+	 *
+	 * @return string
+	 */
+	private static function normalize_option_name( $key ): string {
+		return strtolower( trim( (string) $key ) );
+	}
+
+	/**
+	 * Match candidates against restricted names using the options table collation.
+	 *
+	 * The empty option_name branches give both derived tables the real column type and
+	 * collation without requiring a restricted option row to exist.
+	 *
+	 * @param array<string> $candidate_names Trimmed option names from the blueprint.
+	 *
+	 * @return array<int> Indexes of candidates that match a restricted option.
+	 */
+	private function get_collation_restricted_option_indexes( array $candidate_names ): array {
+		global $wpdb;
+
+		if ( empty( $candidate_names ) || ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->options ) || ! method_exists( $wpdb, 'get_col' ) || ! method_exists( $wpdb, 'prepare' ) ) {
+			return array();
+		}
+
+		$candidate_rows  = implode(
+			' UNION ALL ',
+			array_fill( 0, count( $candidate_names ), 'SELECT %d AS candidate_index, %s AS option_name' )
+		);
+		$restricted_rows = implode(
+			' UNION ALL ',
+			array_fill( 0, count( self::RESTRICTED_OPTIONS ), 'SELECT %s AS option_name' )
+		);
+		$query_args      = array();
+
+		foreach ( $candidate_names as $candidate_index => $candidate_name ) {
+			$query_args[] = $candidate_index;
+			$query_args[] = $candidate_name;
+		}
+		$query_args = array_merge( $query_args, self::RESTRICTED_OPTIONS );
+
+		$query = "
+			SELECT DISTINCT candidates.candidate_index
+			FROM (
+				SELECT 0 AS candidate_index, option_name FROM {$wpdb->options} WHERE 1 = 0
+				UNION ALL {$candidate_rows}
+			) AS candidates
+			INNER JOIN (
+				SELECT option_name FROM {$wpdb->options} WHERE 1 = 0
+				UNION ALL {$restricted_rows}
+			) AS restricted_options USING ( option_name )
+		";
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- A single prepared comparison applies the options table's collation without an N-query loop.
+		$restricted_indexes = $wpdb->get_col( $wpdb->prepare( $query, $query_args ) );
+
+		return array_map( 'intval', $restricted_indexes );
+	}
+
 	/**
 	 * Get the step class.
 	 *
diff --git a/packages/php/blueprint/tests/Unit/Importers/ImportSetSiteOptionsTest.php b/packages/php/blueprint/tests/Unit/Importers/ImportSetSiteOptionsTest.php
index cdf1c19aa33..69c155a1b4a 100644
--- a/packages/php/blueprint/tests/Unit/Importers/ImportSetSiteOptionsTest.php
+++ b/packages/php/blueprint/tests/Unit/Importers/ImportSetSiteOptionsTest.php
@@ -155,6 +155,155 @@ class ImportSetSiteOptionsTest extends TestCase {
 		$this->assertNotEquals( get_option( 'active_plugins' ), array( 'fake-plugin/fake-plugin.php' ) );
 	}

+	/**
+	 * Test restricted option spelling variants.
+	 *
+	 * @testdox Restricted options are matched regardless of key case, padding, or database collation.
+	 */
+	public function test_process_restricted_options_ignores_key_case_and_whitespace(): void {
+		$accented_key    = "wp_us\u{00e9}r_roles";
+		$schema          = Mockery::mock();
+		$schema->options = array(
+			'wp_USER_roles' => array( 'customer' => array( 'capabilities' => array( 'manage_options' => true ) ) ),
+			'ADMIN_EMAIL'   => 'danger@example.com',
+			' siteurl'      => 'https://evil.example.com',
+			$accented_key   => array( 'customer' => array( 'capabilities' => array( 'manage_options' => true ) ) ),
+		);
+
+		$sut = Mockery::mock( ImportSetSiteOptions::class )
+			->makePartial()
+			->shouldAllowMockingProtectedMethods();
+
+		// Any write at all means the restriction was bypassed.
+		$sut->shouldNotReceive( 'wp_update_option' );
+
+		$result = $sut->process( $schema );
+
+		$this->assertTrue( $result->is_success() );
+
+		$messages = $result->get_messages( 'warn' );
+		$this->assertCount( 4, $messages );
+		$this->assertEquals( "Cannot modify 'wp_USER_roles' option: Modifying is restricted for this key.", $messages[0]['message'] );
+		$this->assertEquals( "Cannot modify 'ADMIN_EMAIL' option: Modifying is restricted for this key.", $messages[1]['message'] );
+		$this->assertEquals( "Cannot modify ' siteurl' option: Modifying is restricted for this key.", $messages[2]['message'] );
+		$this->assertEquals( "Cannot modify '{$accented_key}' option: Modifying is restricted for this key.", $messages[3]['message'] );
+
+		$this->assertNotEquals( 'danger@example.com', get_option( 'admin_email' ) );
+		$this->assertNotEquals( 'https://evil.example.com', get_option( 'siteurl' ) );
+	}
+
+	/**
+	 * Test an accent-equivalent key without a canonical row.
+	 *
+	 * @testdox Accent-equivalent keys remain restricted when the canonical option row is absent.
+	 */
+	public function test_process_restricts_collation_equivalent_key_without_canonical_row(): void {
+		global $wpdb;
+
+		$canonical_key = 'rewrite_rules';
+		$accented_key  = "r\u{00e9}write_rules";
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Preserve any equivalent row while testing its absence.
+		$original_row = $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT option_name, option_value, autoload FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
+				$canonical_key
+			),
+			ARRAY_A
+		);
+
+		delete_option( $canonical_key );
+		delete_option( $accented_key );
+
+		try {
+			$schema          = Mockery::mock();
+			$schema->options = array( $accented_key => array( 'marker' => 'blocked' ) );
+			$sut             = new ImportSetSiteOptions();
+
+			$result = $sut->process( $schema );
+
+			$this->assertTrue( $result->is_success() );
+			$messages = $result->get_messages( 'warn' );
+			$this->assertCount( 1, $messages );
+			$this->assertEquals( "Cannot modify '{$accented_key}' option: Modifying is restricted for this key.", $messages[0]['message'] );
+
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the bypass did not create an exact variant row.
+			$stored_variant = $wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT option_name FROM {$wpdb->options} WHERE BINARY option_name = %s LIMIT 1",
+					$accented_key
+				)
+			);
+			$this->assertNull( $stored_variant );
+		} finally {
+			delete_option( $canonical_key );
+			delete_option( $accented_key );
+			if ( is_array( $original_row ) ) {
+				add_option( $original_row['option_name'], maybe_unserialize( $original_row['option_value'] ), '', $original_row['autoload'] );
+			}
+		}
+	}
+
+	/**
+	 * Test batching of database collation checks.
+	 *
+	 * @testdox Multiple imported option names use one database-collation query.
+	 */
+	public function test_process_batches_database_collation_checks(): void {
+		global $wpdb;
+
+		$schema          = Mockery::mock();
+		$schema->options = array(
+			'blueprint_alpha' => 'one',
+			'blueprint_beta'  => 'two',
+			'blueprint_gamma' => 'three',
+		);
+		$sut             = Mockery::mock( ImportSetSiteOptions::class )
+			->makePartial()
+			->shouldAllowMockingProtectedMethods();
+
+		$sut->shouldReceive( 'wp_update_option' )->times( 3 )->andReturn( true );
+		$sut->shouldReceive( 'wp_get_option' )->with( 'blueprint_alpha' )->andReturn( 'one' );
+		$sut->shouldReceive( 'wp_get_option' )->with( 'blueprint_beta' )->andReturn( 'two' );
+		$sut->shouldReceive( 'wp_get_option' )->with( 'blueprint_gamma' )->andReturn( 'three' );
+
+		$query_count_before = $wpdb->num_queries;
+		$result             = $sut->process( $schema );
+
+		$this->assertTrue( $result->is_success() );
+		$this->assertSame( 1, $wpdb->num_queries - $query_count_before );
+	}
+
+	/**
+	 * Test that similar unrestricted option names remain writable.
+	 *
+	 * @testdox Options that merely contain a restricted name remain writable.
+	 */
+	public function test_process_allows_options_that_only_resemble_restricted_ones(): void {
+		$schema          = Mockery::mock();
+		$schema->options = array( 'my_siteurl_backup' => 'https://example.com' );
+
+		$sut = Mockery::mock( ImportSetSiteOptions::class )
+			->makePartial()
+			->shouldAllowMockingProtectedMethods();
+
+		$sut->shouldReceive( 'wp_update_option' )
+			->with( 'my_siteurl_backup', 'https://example.com' )
+			->andReturn( true );
+		$sut->shouldReceive( 'wp_get_option' )
+			->with( 'my_siteurl_backup' )
+			->andReturn( 'https://example.com' );
+
+		$result = $sut->process( $schema );
+
+		$this->assertTrue( $result->is_success() );
+		$this->assertCount( 0, $result->get_messages( 'warn' ) );
+
+		$messages = $result->get_messages( 'info' );
+		$this->assertCount( 1, $messages );
+		$this->assertEquals( 'my_siteurl_backup has been updated.', $messages[0]['message'] );
+	}
+
 	/**
 	 * Test getting the step class.
 	 *
diff --git a/plugins/woocommerce/changelog/woo6-96-fix-restricted-options-case-bypass b/plugins/woocommerce/changelog/woo6-96-fix-restricted-options-case-bypass
new file mode 100644
index 00000000000..b476a0f0c55
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-96-fix-restricted-options-case-bypass
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Blueprint: match the restricted site options list regardless of the casing, padding or accents used in an imported key, so a variant spelling can no longer overwrite a restricted option such as wp_user_roles.