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.