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