Commit 353c095f35b for woocommerce

commit 353c095f35b383f79518230ead4488150b084566
Author: Liam Sarsfield <43409125+LiamSarsfield@users.noreply.github.com>
Date:   Mon Aug 10 14:05:44 2026 +0100

    Fix Store API trusting cart token without validating it (#67545)

    Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>

diff --git a/plugins/woocommerce/bin/phpcs/WooCommerceStoreApi/Sniffs/StoreApi/CartTokenSourceSniff.php b/plugins/woocommerce/bin/phpcs/WooCommerceStoreApi/Sniffs/StoreApi/CartTokenSourceSniff.php
new file mode 100644
index 00000000000..296dba3206b
--- /dev/null
+++ b/plugins/woocommerce/bin/phpcs/WooCommerceStoreApi/Sniffs/StoreApi/CartTokenSourceSniff.php
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Forbids reading the Store API cart token from anywhere but CartTokenUtils.
+ *
+ * @package WooCommerce\Sniffs
+ */
+
+declare( strict_types=1 );
+
+namespace WooCommerceStoreApi\Sniffs\StoreApi;
+
+use PHP_CodeSniffer\Files\File;
+use PHP_CodeSniffer\Sniffs\Sniff;
+
+/**
+ * The cart token selects which customer session a request loads. It must always be read from a unified palce the via CartTokenUtils::get_request_cart_token().
+ *
+ */
+class CartTokenSourceSniff implements Sniff {
+
+	/**
+	 * Superglobal key holding the raw cart token.
+	 */
+	private const SERVER_KEY = 'HTTP_CART_TOKEN';
+
+	/**
+	 * Request header holding the raw cart token.
+	 */
+	private const HEADER_NAME = 'cart-token';
+
+	/**
+	 * Returns the token types this sniff is interested in.
+	 *
+	 * @return array<int|string>
+	 */
+	public function register() {
+		return array( T_VARIABLE, T_STRING );
+	}
+
+	/**
+	 * Processes this test when one of its tokens is encountered.
+	 *
+	 * @param File $phpcsFile The file being scanned.
+	 * @param int  $stackPtr  The position of the current token in the stack.
+	 * @return void
+	 */
+	public function process( File $phpcsFile, $stackPtr ) {
+		$tokens = $phpcsFile->getTokens();
+
+		if ( T_VARIABLE === $tokens[ $stackPtr ]['code'] ) {
+			$this->process_superglobal( $phpcsFile, $stackPtr );
+			return;
+		}
+
+		$this->process_request_header( $phpcsFile, $stackPtr );
+	}
+
+	/**
+	 * Flags `$_SERVER['HTTP_CART_TOKEN']`.
+	 *
+	 * @param File $phpcsFile The file being scanned.
+	 * @param int  $stackPtr  Position of the variable token.
+	 * @return void
+	 */
+	private function process_superglobal( File $phpcsFile, int $stackPtr ): void {
+		$tokens = $phpcsFile->getTokens();
+
+		if ( '$_SERVER' !== $tokens[ $stackPtr ]['content'] ) {
+			return;
+		}
+
+		$open = $phpcsFile->findNext( T_WHITESPACE, ( $stackPtr + 1 ), null, true );
+		if ( false === $open || T_OPEN_SQUARE_BRACKET !== $tokens[ $open ]['code'] ) {
+			return;
+		}
+
+		$key = $phpcsFile->findNext( T_WHITESPACE, ( $open + 1 ), null, true );
+		if ( false === $key || T_CONSTANT_ENCAPSED_STRING !== $tokens[ $key ]['code'] ) {
+			return;
+		}
+
+		if ( self::SERVER_KEY !== strtoupper( trim( $tokens[ $key ]['content'], "'\"" ) ) ) {
+			return;
+		}
+
+		if ( $this->is_assignment_target( $phpcsFile, $open ) ) {
+			$phpcsFile->addError(
+				'Assigning to $_SERVER[\'%s\'] changes which customer session loads for the rest of the request. Only do this with an already validated token, and justify it with a phpcs:ignore.',
+				$stackPtr,
+				'ServerSuperglobalWrite',
+				array( self::SERVER_KEY )
+			);
+			return;
+		}
+
+		$phpcsFile->addError(
+			'Do not read $_SERVER[\'%s\'] directly. Use CartTokenUtils::get_request_cart_token() so the cart token is read from a single place.',
+			$stackPtr,
+			'ServerSuperglobal',
+			array( self::SERVER_KEY )
+		);
+	}
+
+	/**
+	 * Whether the array access starting at $open is being written to.
+	 *
+	 * @param File $phpcsFile The file being scanned.
+	 * @param int  $open      Position of the opening square bracket.
+	 * @return bool
+	 */
+	private function is_assignment_target( File $phpcsFile, int $open ): bool {
+		$tokens = $phpcsFile->getTokens();
+		$closer = $tokens[ $open ]['bracket_closer'] ?? null;
+
+		if ( null === $closer ) {
+			return false;
+		}
+
+		$next = $phpcsFile->findNext( T_WHITESPACE, ( $closer + 1 ), null, true );
+
+		return false !== $next && isset( \PHP_CodeSniffer\Util\Tokens::$assignmentTokens[ $tokens[ $next ]['code'] ] );
+	}
+
+	/**
+	 * Flags `$request->get_header( 'Cart-Token' )`.
+	 *
+	 * @param File $phpcsFile The file being scanned.
+	 * @param int  $stackPtr  Position of the string token.
+	 * @return void
+	 */
+	private function process_request_header( File $phpcsFile, int $stackPtr ): void {
+		$tokens = $phpcsFile->getTokens();
+
+		if ( 'get_header' !== strtolower( $tokens[ $stackPtr ]['content'] ) ) {
+			return;
+		}
+
+		$before = $phpcsFile->findPrevious( T_WHITESPACE, ( $stackPtr - 1 ), null, true );
+		if ( false === $before || T_OBJECT_OPERATOR !== $tokens[ $before ]['code'] ) {
+			return;
+		}
+
+		$open = $phpcsFile->findNext( T_WHITESPACE, ( $stackPtr + 1 ), null, true );
+		if ( false === $open || T_OPEN_PARENTHESIS !== $tokens[ $open ]['code'] ) {
+			return;
+		}
+
+		$argument = $phpcsFile->findNext( T_WHITESPACE, ( $open + 1 ), null, true );
+		if ( false === $argument || T_CONSTANT_ENCAPSED_STRING !== $tokens[ $argument ]['code'] ) {
+			return;
+		}
+
+		if ( self::HEADER_NAME !== strtolower( trim( $tokens[ $argument ]['content'], "'\"" ) ) ) {
+			return;
+		}
+
+		$phpcsFile->addError(
+			'Do not read the Cart-Token header off a WP_REST_Request; under /batch that is the sub-request. Use CartTokenUtils::get_request_cart_token().',
+			$stackPtr,
+			'RequestHeader'
+		);
+	}
+}
diff --git a/plugins/woocommerce/changelog/woo6-85-store-api-cart-token-lint-rule b/plugins/woocommerce/changelog/woo6-85-store-api-cart-token-lint-rule
new file mode 100644
index 00000000000..b0944ec4fe4
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-85-store-api-cart-token-lint-rule
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add a PHP_CodeSniffer rule requiring the Store API cart token to be read through CartTokenUtils::get_request_cart_token().
diff --git a/plugins/woocommerce/changelog/woo6-85-store-api-cart-token-source b/plugins/woocommerce/changelog/woo6-85-store-api-cart-token-source
new file mode 100644
index 00000000000..f1d44911968
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-85-store-api-cart-token-source
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: Always resolve the cart token from a consistent place.
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 59385c5e13b..4b3d6a95997 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -17,6 +17,8 @@
 	<exclude-pattern>*/vendor/*</exclude-pattern>
 	<exclude-pattern>lib/</exclude-pattern>
 	<exclude-pattern>php-stubs/</exclude-pattern>
+	<!-- Custom PHPCS sniffs: must follow the PHP_CodeSniffer API naming, not WordPress'. -->
+	<exclude-pattern>bin/phpcs/</exclude-pattern>
 	<!-- GraphQL test fixtures used only by unit tests. -->
 	<exclude-pattern>tests/php/src/Internal/Api/Fixtures/</exclude-pattern>
 	<!-- Fixture PHP files used in e2e tests. -->
@@ -44,6 +46,13 @@
 	<!-- Rules -->
 	<rule ref="WooCommerce-Core" />

+	<!-- The cart token selects which customer session loads; it must be read from one place only. -->
+	<rule ref="./bin/phpcs/WooCommerceStoreApi/Sniffs/StoreApi/CartTokenSourceSniff.php">
+		<!-- The canonical accessor, and tests that simulate raw request headers. -->
+		<exclude-pattern>src/StoreApi/Utilities/CartTokenUtils.php</exclude-pattern>
+		<exclude-pattern>tests/</exclude-pattern>
+	</rule>
+
 	<rule ref="WooCommerce.Functions.InternalInjectionMethod">
 		<include-pattern>src/</include-pattern>
 		<include-pattern>tests/php/src/</include-pattern>
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 37f35b09bcd..7041e2d218b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -68214,12 +68214,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Authentication.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\StoreApi\\Authentication\:\:get_cart_token\(\) should return string but returns array\|string\.$#'
-			identifier: return.type
-			count: 1
-			path: src/StoreApi/Authentication.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\StoreApi\\Authentication\:\:init\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -70578,12 +70572,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/SessionHandler.php

-		-
-			message: '#^Property Automattic\\WooCommerce\\StoreApi\\SessionHandler\:\:\$token \(string\) does not accept array\|string\.$#'
-			identifier: assign.propertyType
-			count: 1
-			path: src/StoreApi/SessionHandler.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\StoreApi\\StoreApi\:\:init\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/StoreApi/Authentication.php b/plugins/woocommerce/src/StoreApi/Authentication.php
index f70ab87af7a..d5d0ca11a7f 100644
--- a/plugins/woocommerce/src/StoreApi/Authentication.php
+++ b/plugins/woocommerce/src/StoreApi/Authentication.php
@@ -52,8 +52,7 @@ class Authentication {
 			return $handler;
 		}

-		$cart_token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? '' ) );
-		$cart_token = is_string( $cart_token ) ? $cart_token : '';
+		$cart_token = CartTokenUtils::get_request_cart_token();
 		if ( $cart_token && CartTokenUtils::validate_cart_token( $cart_token ) ) {
 			return SessionHandler::class;
 		}
@@ -145,11 +144,11 @@ class Authentication {
 	/**
 	 * Gets the cart token from the request header.
 	 *
-	 * @param \WP_REST_Request $request The REST request instance.
+	 * @param \WP_REST_Request $request Deprecated since 11.1.0. Unused; kept for subclasses.
 	 * @return string
 	 */
-	protected function get_cart_token( \WP_REST_Request $request ) {
-		return wc_clean( wp_unslash( $request->get_header( 'Cart-Token' ) ?? '' ) );
+	protected function get_cart_token( \WP_REST_Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Signature kept for backwards compatibility.
+		return CartTokenUtils::get_request_cart_token();
 	}

 	/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
index 84d680e3526..98b565ae0f0 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
@@ -204,12 +204,14 @@ abstract class AbstractCartRoute extends AbstractRoute {
 	/**
 	 * Checks if the request has a valid cart token.
 	 *
-	 * @param \WP_REST_Request $request Request object.
+	 * Reads the outer HTTP header, not `$request` one, to avoid conflicting cart tokens on a batch request.
+	 *
+	 * @param \WP_REST_Request $request Request object. Unused here; kept for subclasses.
 	 * @return bool
 	 */
 	protected function has_cart_token( \WP_REST_Request $request ) {
 		if ( is_null( $this->has_cart_token ) ) {
-			$this->has_cart_token = CartTokenUtils::validate_cart_token( $request->get_header( 'Cart-Token' ) ?? '' );
+			$this->has_cart_token = CartTokenUtils::validate_cart_token( CartTokenUtils::get_request_cart_token() );
 		}
 		return $this->has_cart_token;
 	}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
index 9a66251da8a..2fa8230efc8 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
@@ -188,6 +188,7 @@ class CheckoutSessionsComplete extends AbstractCartRoute {
 		// This allows the session will be loaded later without any further intervention.
 		if ( true === $this->has_cart_token ) {
 			$request->set_header( 'Cart-Token', $session_id );
+			// phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobalWrite -- $session_id was validated above; this keeps the consumed token in sync with the validated one.
 			$_SERVER['HTTP_CART_TOKEN'] = $session_id;
 		}

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsUpdate.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsUpdate.php
index bfb83385f75..22512e3c451 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsUpdate.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Agentic/CheckoutSessionsUpdate.php
@@ -155,6 +155,7 @@ class CheckoutSessionsUpdate extends AbstractCartRoute {
 		// This allows the session will be loaded later without any further intervention.
 		if ( true === $this->has_cart_token ) {
 			$request->set_header( 'Cart-Token', $session_id );
+			// phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobalWrite -- $session_id was validated above; this keeps the consumed token in sync with the validated one.
 			$_SERVER['HTTP_CART_TOKEN'] = $session_id;
 		}

diff --git a/plugins/woocommerce/src/StoreApi/SessionHandler.php b/plugins/woocommerce/src/StoreApi/SessionHandler.php
index 24fc73eda70..33c05b12139 100644
--- a/plugins/woocommerce/src/StoreApi/SessionHandler.php
+++ b/plugins/woocommerce/src/StoreApi/SessionHandler.php
@@ -44,7 +44,7 @@ final class SessionHandler extends WC_Session {
 	 * Constructor for the session class.
 	 */
 	public function __construct() {
-		$this->token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? '' ) );
+		$this->token = CartTokenUtils::get_request_cart_token();
 		$this->table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions';
 	}

@@ -58,8 +58,17 @@ final class SessionHandler extends WC_Session {

 	/**
 	 * Process the token header to load the correct session.
+	 *
+	 * Verifies the signature here rather than trusting the caller that selected this handler.
 	 */
 	protected function init_session_from_token() {
+		if ( ! CartTokenUtils::validate_cart_token( $this->token ) ) {
+			$this->_customer_id       = $this->generate_customer_id();
+			$this->session_expiration = CartTokenUtils::get_cart_token_expiration();
+			$this->_data              = array();
+			return;
+		}
+
 		$payload = CartTokenUtils::get_cart_token_payload( $this->token );

 		$this->_customer_id       = $payload['user_id'];
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartTokenUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/CartTokenUtils.php
index 30af83f4ba3..17101725c0c 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartTokenUtils.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartTokenUtils.php
@@ -31,6 +31,18 @@ class CartTokenUtils {
 		);
 	}

+	/**
+	 * Get the cart token sent with the current HTTP request.
+	 *
+	 * @since 11.1.0
+	 * @return string
+	 */
+	public static function get_request_cart_token(): string {
+		$cart_token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? '' ) );
+
+		return is_string( $cart_token ) ? $cart_token : '';
+	}
+
 	/**
 	 * Validate the cart token.
 	 *
@@ -44,10 +56,21 @@ class CartTokenUtils {
 	/**
 	 * Get the cart token payload.
 	 *
+	 * Returns an empty payload unless the signature validates.
+	 *
+	 * @since 11.1.0 Returns an empty payload for tokens that fail signature validation.
 	 * @param string $cart_token The cart token.
 	 * @return array
 	 */
 	public static function get_cart_token_payload( string $cart_token ): array {
+		if ( ! self::validate_cart_token( $cart_token ) ) {
+			return array(
+				'user_id' => '',
+				'exp'     => 0,
+				'iss'     => '',
+			);
+		}
+
 		$parts = JsonWebToken::get_parts( $cart_token )->payload;

 		return array(
@@ -69,9 +92,10 @@ class CartTokenUtils {
 	/**
 	 * Gets the expiration of the cart token. Defaults to 48h.
 	 *
+	 * @since 11.1.0 Made public.
 	 * @return int
 	 */
-	private static function get_cart_token_expiration(): int {
+	public static function get_cart_token_expiration(): int {
 		/**
 		 * Filters the session expiration.
 		 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
index 86d42fd0e4f..a1058d80d23 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -732,6 +732,46 @@ class Cart extends ControllerTestCase {
 		}
 	}

+	/**
+	 * Nested Cart-Token should be ignored in a batch request.
+	 */
+	public function test_batch_sub_request_cart_token_does_not_waive_nonce() {
+		$token = CartTokenUtils::get_cart_token( (string) wc()->session->get_customer_id() );
+
+		// Preserve globals.
+		$old_server = $_SERVER;
+
+		try {
+			$_SERVER['REQUEST_URI'] = '/' . rest_get_url_prefix() . '/wc/store/v1/batch';
+			unset( $_SERVER['HTTP_CART_TOKEN'] );
+
+			$request = new \WP_REST_Request( 'POST', '/wc/store/v1/batch' );
+			$request->set_header( 'Content-Type', 'application/json' );
+			$request->set_body(
+				wp_json_encode(
+					array(
+						'requests' => array(
+							array(
+								'method'  => 'POST',
+								'path'    => '/wc/store/v1/cart/update-customer',
+								'headers' => array( 'Cart-Token' => $token ),
+								'body'    => array( 'billing_address' => array( 'first_name' => 'Nonce-free' ) ),
+							),
+						),
+					)
+				)
+			);
+
+			$response = rest_get_server()->dispatch( $request );
+			$data     = $response->get_data();
+
+			$this->assertSame( 401, $data['responses'][0]['status'] ?? null, 'A sub-request token must not waive the nonce check.' );
+		} finally {
+			// Restore globals.
+			$_SERVER = $old_server;
+		}
+	}
+
 	/**
 	 * Test that cart GET endpoint sends Cache-Control headers.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/SessionHandlerTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/SessionHandlerTest.php
index 82f2aa73496..1f893be44ff 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/SessionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/SessionHandlerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Blocks\StoreApi;

 use Automattic\WooCommerce\StoreApi\SessionHandler;
+use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
 use WC_Session;
 use WC_Unit_Test_Case;

@@ -120,4 +121,30 @@ class SessionHandlerTest extends WC_Unit_Test_Case {
 		$this->sut->set( 'test_key', 'test_value' );
 		$this->assertSame( 'test_value', $this->sut->get( 'test_key' ), 'Should return the value that was set' );
 	}
+
+	/**
+	 * @testdox A guest cart token loads the session it names.
+	 */
+	public function test_guest_token_loads_the_session_it_names(): void {
+		$_SERVER['HTTP_CART_TOKEN'] = CartTokenUtils::get_cart_token( 't_guest_session' );
+
+		$handler = new SessionHandler();
+		$handler->init();
+
+		$this->assertSame( 't_guest_session', $handler->get_customer_id(), 'A guest token should address its own session' );
+	}
+
+	/**
+	 * @testdox A registered customer cart token loads the session it names.
+	 */
+	public function test_customer_token_loads_the_session_it_names(): void {
+		$customer_id = (string) $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$_SERVER['HTTP_CART_TOKEN'] = CartTokenUtils::get_cart_token( $customer_id );
+
+		$handler = new SessionHandler();
+		$handler->init();
+
+		$this->assertSame( $customer_id, $handler->get_customer_id(), 'A customer token should address its own session' );
+	}
 }