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 );
+	}
+}