Commit efe1eaec72c for woocommerce
commit efe1eaec72ce286266e1dd1139ab47f1579f032d
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date: Fri Sep 18 12:43:26 2026 +0200
Update request validation and session handling (#68858)
diff --git a/plugins/woocommerce/changelog/fix-mobile-app-login-permissions b/plugins/woocommerce/changelog/fix-mobile-app-login-permissions
new file mode 100644
index 00000000000..a02d17b2953
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-mobile-app-login-permissions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update mobile app login permission checks.
diff --git a/plugins/woocommerce/changelog/fix-multisite-options-permissions b/plugins/woocommerce/changelog/fix-multisite-options-permissions
new file mode 100644
index 00000000000..d406435f5f8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-multisite-options-permissions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update legacy options API permission checks.
diff --git a/plugins/woocommerce/changelog/fix-session-cookie-verification b/plugins/woocommerce/changelog/fix-session-cookie-verification
new file mode 100644
index 00000000000..e44791a20ec
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-session-cookie-verification
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update session cookie validation.
diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php
index 5c995259363..73588e3b671 100644
--- a/plugins/woocommerce/includes/class-wc-session-handler.php
+++ b/plugins/woocommerce/includes/class-wc-session-handler.php
@@ -320,24 +320,12 @@ class WC_Session_Handler extends WC_Session {
/**
* Verify a hash produced by self::hash().
*
- * Hashes produced by the previous `wp_fast_hash()` implementation are still accepted so that guest sessions
- * created before this change are not invalidated. That fallback can be removed in 11.1.0 forward after those cookies have expired.
- *
* @param string $message Message to verify.
* @param string $hash Hash to verify.
* @return bool Whether the hash is valid.
*/
private function verify_hash( string $message, string $hash ) {
- if ( hash_equals( $this->hash( $message ), $hash ) ) {
- return true;
- }
-
- // `wp_fast_hash()` prefixes its output with `$generic$`, so only those cookies take the legacy path.
- if ( function_exists( 'wp_verify_fast_hash' ) && str_starts_with( $hash, '$generic$' ) ) {
- return wp_verify_fast_hash( $message, $hash );
- }
-
- return false;
+ return hash_equals( $this->hash( $message ), $hash );
}
/**
diff --git a/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
index d037e5c6137..c5bf19ca385 100644
--- a/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
+++ b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
@@ -419,19 +419,36 @@ class MobileAppQRLogin extends \WC_REST_Data_Controller {
}
/**
- * Check whether the current user can generate a QR login token.
+ * Check whether the current user can access the QR login browser endpoints.
*
- * Requires the `manage_woocommerce` capability, which covers administrators and
- * shop managers out of the box. The check is deliberately explicit (not routed
- * through `wc_rest_check_manager_permissions()`) so it cannot be loosened by the
+ * These endpoints back an interactive wp-admin flow, so they require a real
+ * logged-in browser session — a valid `logged_in` cookie for the current user
+ * plus a matching `wp_rest` nonce — as well as the `manage_woocommerce`
+ * capability. The capability check is deliberately explicit (not routed through
+ * `wc_rest_check_manager_permissions()`) so it cannot be loosened by the
* `woocommerce_rest_check_permissions` filter that other Admin API endpoints share.
*
- * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
- * @return \WP_Error|bool True if the user has the required capability, WP_Error otherwise.
+ * @param \WP_REST_Request<array<string, mixed>> $request The REST request.
+ * @return \WP_Error|bool True if the user is allowed, WP_Error otherwise.
*/
public function get_items_permissions_check( $request ) {
- unset( $request );
- // Parameter required by WP REST contract but unused here.
+ $user_id = get_current_user_id();
+ if ( ! $user_id || (int) wp_validate_auth_cookie( '', 'logged_in' ) !== $user_id ) {
+ return new \WP_Error(
+ 'woocommerce_rest_qr_login_missing_session',
+ __( 'This action requires an interactive admin session.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $nonce = $request->get_header( 'X-WP-Nonce' );
+ if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
+ return new \WP_Error(
+ 'woocommerce_rest_qr_login_missing_session',
+ __( 'This action requires an interactive admin session.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new \WP_Error(
diff --git a/plugins/woocommerce/src/Admin/API/Options.php b/plugins/woocommerce/src/Admin/API/Options.php
index b608334b1d4..71698f9a91c 100644
--- a/plugins/woocommerce/src/Admin/API/Options.php
+++ b/plugins/woocommerce/src/Admin/API/Options.php
@@ -113,12 +113,7 @@ class Options extends \WC_REST_Data_Controller {
wc_deprecated_function( 'Automattic\WooCommerce\Admin\API\Options::' . ( $is_update ? 'update_options' : 'get_options' ), '6.3' );
- // Disallow option updates in non-production environments unless the option is whitelisted, prompting developers to create specific endpoints in case they miss the deprecation notice.
- if ( 'production' !== wp_get_environment_type() ) {
- return false;
- }
-
- return current_user_can( 'manage_options' );
+ return false;
}
/**
@@ -195,9 +190,11 @@ class Options extends \WC_REST_Data_Controller {
'woocommerce_ces_shown_for_actions',
'woocommerce_clear_ces_tracks_queue_for_page',
'woocommerce_admin_install_timestamp',
+ 'woocommerce_admin_dismissed_mobile_app_modal',
'woocommerce_task_list_tracked_completed_tasks',
'woocommerce_show_marketplace_suggestions',
'wc_connect_options',
+ 'wcshipping_options',
'woocommerce_admin_created_default_shipping_zones',
'woocommerce_admin_reviewed_default_shipping_zones',
'woocommerce_admin_reviewed_store_location_settings',
@@ -214,11 +211,8 @@ class Options extends \WC_REST_Data_Controller {
'woocommerce_orders_report_date_tour_shown',
'woocommerce_show_prepublish_checks_enabled',
'woocommerce_date_type',
- 'date_format',
- 'time_format',
'woocommerce_onboarding_profile',
'woocommerce_default_country',
- 'blogname',
'wcpay_welcome_page_incentives_dismissed',
'wcpay_welcome_page_viewed_timestamp',
'wcpay_welcome_page_exit_survey_more_info_needed_timestamp',
@@ -240,15 +234,7 @@ class Options extends \WC_REST_Data_Controller {
'wc_admin_helper_feature_values',
);
- $theme_permissions = array(
- 'theme_mods_' . get_stylesheet() => current_user_can( 'edit_theme_options' ),
- 'stylesheet' => current_user_can( 'edit_theme_options' ),
- );
-
- return array_merge(
- array_fill_keys( $theme_permissions, current_user_can( 'edit_theme_options' ) ),
- array_fill_keys( $legacy_whitelisted_options, $is_woocommerce_admin )
- );
+ return array_fill_keys( $legacy_whitelisted_options, $is_woocommerce_admin );
}
/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php b/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php
index d9a7749a3b5..d34d911c4be 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php
@@ -517,78 +517,6 @@ class WC_Tests_Session_Handler extends WC_Unit_Test_Case {
$this->assertSame( array( array( 'customer' ) ), $wpdb->get_results( $wpdb->prepare( "SELECT session_key FROM %i WHERE session_key IN ('guest', 'customer')", "{$wpdb->prefix}woocommerce_sessions" ), ARRAY_N ) );
}
- /**
- * @testdox Test that get_session_cookie accepts a cookie hashed with the current implementation.
- */
- public function test_get_session_cookie_accepts_current_hash(): void {
- $this->set_session_cookie( $this->build_session_cookie_value( 'cust_1', 'current' ) );
-
- $cookie = $this->handler->get_session_cookie();
-
- $this->assertNotFalse( $cookie, 'Cookie hashed with the current implementation should be accepted.' );
- $this->assertSame( 'cust_1', $cookie[0] );
- }
-
- /**
- * @testdox Test that get_session_cookie still accepts a cookie hashed with the legacy wp_fast_hash implementation.
- */
- public function test_get_session_cookie_accepts_legacy_fast_hash(): void {
- if ( ! function_exists( 'wp_fast_hash' ) ) {
- $this->markTestSkipped( 'wp_fast_hash() requires WordPress 6.8 or newer.' );
- }
-
- $this->set_session_cookie( $this->build_session_cookie_value( 'cust_1', 'legacy' ) );
-
- $cookie = $this->handler->get_session_cookie();
-
- $this->assertNotFalse( $cookie, 'Cookie hashed with wp_fast_hash() should still be accepted so existing guest sessions survive.' );
- $this->assertSame( 'cust_1', $cookie[0] );
- }
-
- /**
- * @testdox Test that get_session_cookie rejects a cookie with a tampered hash.
- */
- public function test_get_session_cookie_rejects_tampered_hash(): void {
- $this->set_session_cookie( $this->build_session_cookie_value( 'cust_1', 'current' ) . 'tampered' );
-
- $this->assertFalse( $this->handler->get_session_cookie() );
- }
-
- /**
- * @testdox Test that get_session_cookie rejects a cookie whose customer ID no longer matches the hash.
- */
- public function test_get_session_cookie_rejects_tampered_customer_id(): void {
- $cookie_value = $this->build_session_cookie_value( 'cust_1', 'current' );
- $this->set_session_cookie( str_replace( 'cust_1', 'cust_2', $cookie_value ) );
-
- $this->assertFalse( $this->handler->get_session_cookie() );
- }
-
- /**
- * Helper function to build a session cookie value for the handler under test.
- *
- * @param string $customer_id Customer ID to embed in the cookie.
- * @param string $hash_type Either 'current' for the wp_hash() based tag, or 'legacy' for a wp_fast_hash() tag.
- * @return string
- */
- protected function build_session_cookie_value( string $customer_id, string $hash_type ): string {
- $session_expiration = time() + DAY_IN_SECONDS;
- $session_expiring = $session_expiration - HOUR_IN_SECONDS;
- $message = $customer_id . '|' . $session_expiration;
- $cookie_hash = 'legacy' === $hash_type ? wp_fast_hash( $message ) : hash_hmac( 'md5', $message, wp_hash( $message ) );
-
- return implode( '|', array( $customer_id, $session_expiration, $session_expiring, $cookie_hash ) );
- }
-
- /**
- * Helper function to set the session cookie as if it were passed by the browser.
- *
- * @param string $cookie_value Raw cookie value.
- */
- protected function set_session_cookie( string $cookie_value ) {
- $_COOKIE[ $this->get_session_cookie_name() ] = $cookie_value;
- }
-
/**
* Helper function to read the cookie name used by the handler under test.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
index c9eec80d728..ce87b3a90c3 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
@@ -224,6 +224,9 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
// Default REMOTE_ADDR for exchange IP bucketing tests.
$_SERVER['REMOTE_ADDR'] = '203.0.113.10';
+
+ // Start each test without a logged-in cookie; helpers set one when needed.
+ unset( $_COOKIE[ LOGGED_IN_COOKIE ] );
}
/**
@@ -231,6 +234,7 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
*/
public function tearDown(): void {
wp_set_current_user( 0 );
+ unset( $_COOKIE[ LOGGED_IN_COOKIE ] );
// Clear any QR login data the tests may have written.
$this->delete_all_qr_login_data();
@@ -462,6 +466,36 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
return MobileAppQRLogin::APPROVE_CLAIM_OPTION_PREFIX . $this->token_hash( $token );
}
+ /**
+ * Attach a valid `wp_rest` nonce for the current user, mirroring the
+ * `X-WP-Nonce` header the wc-admin React client sends on every apiFetch
+ * request. The QR login browser endpoints require it to prove an
+ * interactive session.
+ *
+ * @param WP_REST_Request $request Request to authenticate.
+ * @return void
+ */
+ private function add_rest_nonce( WP_REST_Request $request ): void {
+ $this->set_logged_in_cookie();
+ $request->set_header( 'X-WP-Nonce', wp_create_nonce( 'wp_rest' ) );
+ }
+
+ /**
+ * Set a valid `logged_in` cookie for the current user, so the endpoints'
+ * interactive-session check passes. No-op for logged-out requests.
+ *
+ * @return void
+ */
+ private function set_logged_in_cookie(): void {
+ $user_id = get_current_user_id();
+ if ( ! $user_id ) {
+ return;
+ }
+ $expiration = time() + DAY_IN_SECONDS;
+ $token = \WP_Session_Tokens::get_instance( $user_id )->create( $expiration );
+ $_COOKIE[ LOGGED_IN_COOKIE ] = wp_generate_auth_cookie( $user_id, $expiration, 'logged_in', $token );
+ }
+
/**
* Issue a POST to the token-generation endpoint.
*
@@ -469,6 +503,7 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
*/
private function dispatch_generate(): \WP_REST_Response {
$request = new WP_REST_Request( 'POST', self::TOKEN_ENDPOINT );
+ $this->add_rest_nonce( $request );
return $this->server->dispatch( $request );
}
@@ -529,6 +564,7 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
*/
private function dispatch_approve( ?string $token, ?string $choice ): \WP_REST_Response {
$request = new WP_REST_Request( 'POST', self::APPROVE_ENDPOINT );
+ $this->add_rest_nonce( $request );
if ( null !== $token ) {
$request->set_param( 'token', $token );
}
@@ -619,6 +655,7 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
*/
private function dispatch_status( ?string $token ): \WP_REST_Response {
$request = new WP_REST_Request( 'POST', self::STATUS_ENDPOINT );
+ $this->add_rest_nonce( $request );
if ( null !== $token ) {
$request->set_param( 'token', $token );
}
@@ -633,6 +670,7 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
*/
private function dispatch_revoke( ?string $uuid ): \WP_REST_Response {
$request = new WP_REST_Request( 'DELETE', self::REVOKE_ENDPOINT );
+ $this->add_rest_nonce( $request );
if ( null !== $uuid ) {
$request->set_param( 'uuid', $uuid );
}
@@ -682,7 +720,7 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
$response = $this->dispatch_generate();
$this->assertSame( rest_authorization_required_code(), $response->get_status() );
- $this->assertSame( 'woocommerce_rest_cannot_view', $response->get_data()['code'] );
+ $this->assertSame( 'woocommerce_rest_qr_login_missing_session', $response->get_data()['code'] );
}
/**
@@ -697,6 +735,49 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
$this->assertSame( 'woocommerce_rest_cannot_view', $response->get_data()['code'] );
}
+ /**
+ * @testdox Token generation rejects an administrator session that carries no REST nonce.
+ */
+ public function test_generate_token_rejects_missing_nonce(): void {
+ wp_set_current_user( $this->admin_id );
+ $this->set_logged_in_cookie();
+
+ $request = new WP_REST_Request( 'POST', self::TOKEN_ENDPOINT );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status(), 'A cookie session without a wp_rest nonce must be rejected even for an administrator.' );
+ $this->assertSame( 'woocommerce_rest_qr_login_missing_session', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token generation rejects an administrator session carrying an invalid REST nonce.
+ */
+ public function test_generate_token_rejects_invalid_nonce(): void {
+ wp_set_current_user( $this->admin_id );
+ $this->set_logged_in_cookie();
+
+ $request = new WP_REST_Request( 'POST', self::TOKEN_ENDPOINT );
+ $request->set_header( 'X-WP-Nonce', 'not-a-valid-nonce' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status(), 'A cookie session with an invalid wp_rest nonce must be rejected.' );
+ $this->assertSame( 'woocommerce_rest_qr_login_missing_session', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token generation rejects an administrator with a valid nonce but no logged-in cookie.
+ */
+ public function test_generate_token_rejects_valid_nonce_without_cookie(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $request = new WP_REST_Request( 'POST', self::TOKEN_ENDPOINT );
+ $request->set_header( 'X-WP-Nonce', wp_create_nonce( 'wp_rest' ) );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status(), 'A valid nonce without a logged-in cookie must be rejected.' );
+ $this->assertSame( 'woocommerce_rest_qr_login_missing_session', $response->get_data()['code'] );
+ }
+
// -----------------------------------------------------------------------
// Generate: error paths.
// -----------------------------------------------------------------------
@@ -2447,6 +2528,7 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
*/
private function dispatch_availability(): \WP_REST_Response {
$request = new WP_REST_Request( 'GET', self::AVAILABILITY_ENDPOINT );
+ $this->add_rest_nonce( $request );
return $this->server->dispatch( $request );
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/OptionsPermissionsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/OptionsPermissionsTest.php
new file mode 100644
index 00000000000..3283a44d906
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/OptionsPermissionsTest.php
@@ -0,0 +1,185 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API;
+
+use WC_REST_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests permissions for the REST API options controller.
+ */
+class OptionsPermissionsTest extends WC_REST_Unit_Test_Case {
+
+ private const UNLISTED_OPTION = 'unlisted_option';
+
+ /**
+ * @testdox Administrators cannot access an unlisted option.
+ * @testWith [false, false, "Administrator"]
+ * [true, false, "subsite Administrator"]
+ * [true, true, "Super Admin"]
+ *
+ * @param bool $requires_multisite Whether the test requires Multisite.
+ * @param bool $grant_super_admin Whether to grant Super Admin privileges.
+ * @param string $user_description Description of the tested user.
+ */
+ public function test_administrator_cannot_access_unlisted_option( bool $requires_multisite, bool $grant_super_admin, string $user_description ): void {
+ if ( $requires_multisite ) {
+ $this->skipWithoutMultisite();
+ } elseif ( is_multisite() ) {
+ $this->markTestSkipped( 'This test requires a single-site installation.' );
+ }
+
+ $user_id = $this->login_as_administrator();
+ if ( $grant_super_admin ) {
+ grant_super_admin( $user_id );
+ }
+ update_option( self::UNLISTED_OPTION, 'original' );
+
+ if ( $requires_multisite && ! $grant_super_admin ) {
+ $this->assertFalse( is_super_admin( $user_id ), 'The test user should not be a Super Admin.' );
+ $this->assertTrue( current_user_can( 'manage_options' ), 'A subsite Administrator should have manage_options.' );
+ $this->assertFalse( current_user_can( 'manage_network_options' ), 'A subsite Administrator should not have manage_network_options.' );
+ }
+ $this->expect_unlisted_option_deprecations();
+
+ $read_response = $this->get_options( array( self::UNLISTED_OPTION ) );
+ $this->assertSame( 403, $read_response->get_status(), "A {$user_description} should not be able to read an unlisted option." );
+
+ $write_response = $this->post_options( array( self::UNLISTED_OPTION => 'changed' ) );
+ $this->assertSame( 403, $write_response->get_status(), "A {$user_description} should not be able to update an unlisted option." );
+ $this->assertSame( 'original', get_option( self::UNLISTED_OPTION ), 'The denied update should not change the option.' );
+ }
+
+ /**
+ * @testdox An eligible Administrator retains access to allowlisted options.
+ * @dataProvider allowlisted_option_provider
+ *
+ * @param string $option_name Option name.
+ * @param mixed $original_value Original option value.
+ * @param mixed $new_value New option value.
+ */
+ public function test_administrator_can_access_allowlisted_option( string $option_name, $original_value, $new_value ): void {
+ $this->login_as_administrator();
+ update_option( $option_name, $original_value );
+
+ $read_response = $this->get_options( array( $option_name ) );
+ $this->assertSame( 200, $read_response->get_status(), "{$option_name} should retain allowlisted read access." );
+ $this->assertSame( $original_value, $read_response->get_data()[ $option_name ], "{$option_name} should return its stored value." );
+
+ $write_response = $this->post_options( array( $option_name => $new_value ) );
+ $this->assertSame( 200, $write_response->get_status(), "{$option_name} should retain allowlisted write access." );
+ $this->assertSame( $new_value, get_option( $option_name ), "{$option_name} should be updated." );
+ }
+
+ /**
+ * Data provider for allowlisted options.
+ *
+ * @return array<string, array{string, mixed, mixed}>
+ */
+ public function allowlisted_option_provider(): array {
+ return array(
+ 'existing option' => array( 'woocommerce_allow_tracking', 'no', 'yes' ),
+ 'shipping options' => array( 'wcshipping_options', array( 'tos_accepted' => false ), array( 'tos_accepted' => true ) ),
+ 'mobile app dismissal' => array( 'woocommerce_admin_dismissed_mobile_app_modal', 'no', 'yes' ),
+ );
+ }
+
+ /**
+ * @testdox The legacy filter can explicitly grant option access.
+ */
+ public function test_filter_can_grant_option_access(): void {
+ $this->login_as_administrator();
+ update_option( self::UNLISTED_OPTION, 'original' );
+
+ $grant_permission = static function ( array $permissions ): array {
+ $permissions[ self::UNLISTED_OPTION ] = true;
+ return $permissions;
+ };
+ add_filter( 'woocommerce_rest_api_option_permissions', $grant_permission );
+ $this->setExpectedDeprecated( 'woocommerce_rest_api_option_permissions' );
+
+ try {
+ $read_response = $this->get_options( array( self::UNLISTED_OPTION ) );
+ $this->assertSame( 200, $read_response->get_status(), 'A filtered option should be readable.' );
+ $this->assertSame( 'original', $read_response->get_data()[ self::UNLISTED_OPTION ], 'The filtered option should return its stored value.' );
+
+ $write_response = $this->post_options( array( self::UNLISTED_OPTION => 'changed' ) );
+ $this->assertSame( 200, $write_response->get_status(), 'A filtered option should be writable.' );
+ $this->assertSame( 'changed', get_option( self::UNLISTED_OPTION ), 'The filtered option should be updated.' );
+ } finally {
+ remove_filter( 'woocommerce_rest_api_option_permissions', $grant_permission );
+ }
+ }
+
+ /**
+ * @testdox Core options cannot be accessed through the legacy endpoint.
+ * @dataProvider core_option_provider
+ *
+ * @param string $option_name Option name.
+ * @param mixed $original_value Original option value.
+ * @param mixed $new_value New option value.
+ */
+ public function test_core_options_cannot_be_accessed( string $option_name, $original_value, $new_value ): void {
+ $this->login_as_administrator();
+ update_option( $option_name, $original_value );
+ $this->expect_unlisted_option_deprecations();
+
+ $read_response = $this->get_options( array( $option_name ) );
+ $this->assertSame( 403, $read_response->get_status(), "{$option_name} should not be readable." );
+
+ $write_response = $this->post_options( array( $option_name => $new_value ) );
+ $this->assertSame( 403, $write_response->get_status(), "{$option_name} should not be writable." );
+ $this->assertSame( $original_value, get_option( $option_name ), "{$option_name} should remain unchanged." );
+ }
+
+ /**
+ * Data provider for core options.
+ *
+ * @return array<string, array{string, mixed, mixed}>
+ */
+ public function core_option_provider(): array {
+ return array(
+ 'blog name' => array( 'blogname', 'Original store', 'Changed store' ),
+ 'date format' => array( 'date_format', 'F j, Y', 'Y-m-d' ),
+ 'time format' => array( 'time_format', 'g:i a', 'H:i' ),
+ 'stylesheet' => array( 'stylesheet', 'original-theme', 'changed-theme' ),
+ 'theme mods' => array( 'theme_mods_test_theme', array( 'custom_logo' => 123 ), array( 'custom_logo' => 456 ) ),
+ );
+ }
+
+ /**
+ * Expect the deprecation notices emitted by unlisted option requests.
+ */
+ private function expect_unlisted_option_deprecations(): void {
+ $this->setExpectedDeprecated( 'Automattic\\WooCommerce\\Admin\\API\\Options::get_options' );
+ $this->setExpectedDeprecated( 'Automattic\\WooCommerce\\Admin\\API\\Options::update_options' );
+ }
+
+ /**
+ * Send a GET request to the options endpoint.
+ *
+ * @param string[] $options Option names.
+ * @return \WP_REST_Response
+ */
+ private function get_options( array $options ) {
+ $request = new WP_REST_Request( 'GET', '/wc-admin/options' );
+ $request->set_query_params( array( 'options' => implode( ',', $options ) ) );
+
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Send a POST request to the options endpoint.
+ *
+ * @param array<string, mixed> $options Option values.
+ * @return \WP_REST_Response
+ */
+ private function post_options( array $options ) {
+ $request = new WP_REST_Request( 'POST', '/wc-admin/options' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( (string) wp_json_encode( $options ) );
+
+ return $this->server->dispatch( $request );
+ }
+}