Commit 21a50fa4345 for woocommerce

commit 21a50fa4345a924e31ce4ffb25b1a1bb469a3907
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date:   Thu Sep 17 13:08:11 2026 +0200

    Fix apostrophe encoding in classic cart requests (#68556)

    Some security intermediaries reject request bodies that contain a
    literal apostrophe before WooCommerce sees them. encodeURIComponent()
    leaves ' unescaped, so jQuery's serialize() and $.param() both send it
    as typed. #68365 fixed this for the classic checkout and left the cart
    page for a follow-up.

    Percent-encode apostrophes at the request-body boundary in cart.js:
    after serialize() for the shipping calculator, update_cart() and
    quantity_update(), and after $.param() for apply_coupon() and
    remove_coupon_clicked(). The encodeApostrophes() helper is a verbatim
    copy of the one in checkout.js; sharing it would need a new script
    handle and an extra request on both pages. PHP decodes %27 back to '
    when it populates $_POST, so every handler receives the same values as
    before. The coupon endpoints now pass a string to $.ajax instead of a
    plain object, the same change #68365 made for the checkout coupon
    endpoints.

diff --git a/plugins/woocommerce/changelog/fix-68395-cart-apostrophe-encoding b/plugins/woocommerce/changelog/fix-68395-cart-apostrophe-encoding
new file mode 100644
index 00000000000..f3afe0765c3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-68395-cart-apostrophe-encoding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Encode apostrophes in classic cart request data.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/cart.js b/plugins/woocommerce/client/legacy/js/frontend/cart.js
index f704eeaa5c3..b39fb605b9c 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/cart.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/cart.js
@@ -19,6 +19,19 @@ jQuery( function ( $ ) {
 			.replace( '%%endpoint%%', endpoint );
 	};

+	/**
+	 * Percent-encode literal apostrophes in an already URL-encoded request body.
+	 *
+	 * `encodeURIComponent()` leaves `'` alone, so serialized bodies can reach the
+	 * server with literal apostrophes that some WAF rules reject.
+	 *
+	 * @param {string} data URL-encoded request body.
+	 * @return {string} Body with apostrophes encoded as %27.
+	 */
+	function encodeApostrophes( data ) {
+		return data.split( "'" ).join( '%27' );
+	}
+
 	/**
 	 * Check if a node is blocked for processing.
 	 *
@@ -374,7 +387,7 @@ jQuery( function ( $ ) {
 			$.ajax( {
 				type: $form.attr( 'method' ),
 				url: $form.attr( 'action' ),
-				data: $form.serialize(),
+				data: encodeApostrophes( $form.serialize() ),
 				dataType: 'html',
 				success: function ( response ) {
 					update_wc_div( response );
@@ -491,7 +504,7 @@ jQuery( function ( $ ) {
 			$.ajax( {
 				type: $form.attr( 'method' ),
 				url: $form.attr( 'action' ),
-				data: $form.serialize(),
+				data: encodeApostrophes( $form.serialize() ),
 				dataType: 'html',
 				success: function ( response ) {
 					update_wc_div( response, preserve_notices );
@@ -624,7 +637,7 @@ jQuery( function ( $ ) {
 			$.ajax( {
 				type: 'POST',
 				url: get_url( 'apply_coupon' ),
-				data: data,
+				data: encodeApostrophes( $.param( data ) ),
 				dataType: 'html',
 				success: function ( response ) {
 					$(
@@ -682,7 +695,7 @@ jQuery( function ( $ ) {
 			$.ajax( {
 				type: 'POST',
 				url: get_url( 'remove_coupon' ),
-				data: data,
+				data: encodeApostrophes( $.param( data ) ),
 				dataType: 'html',
 				success: function ( response ) {
 					$(
@@ -741,7 +754,7 @@ jQuery( function ( $ ) {
 			$.ajax( {
 				type: $form.attr( 'method' ),
 				url: $form.attr( 'action' ),
-				data: $form.serialize(),
+				data: encodeApostrophes( $form.serialize() ),
 				dataType: 'html',
 				success: function ( response ) {
 					update_wc_div( response );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/cart.js b/plugins/woocommerce/client/legacy/js/frontend/test/cart.js
new file mode 100644
index 00000000000..ffc263cf3e7
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/cart.js
@@ -0,0 +1,358 @@
+/**
+ * @jest-environment jest-fixed-jsdom
+ */
+
+// Fixtures match jQuery 3 serialize(): encodeURIComponent per field, spaces as
+// %20, apostrophes literal. Typing %27 serializes as %2527.
+const CART_URL = 'https://example.test/cart/';
+const COUPON_CODE = "SAVE'10";
+
+const SHIPPING_FORM_SERIALIZED =
+	'calc_shipping_country=US' +
+	"&calc_shipping_state=O'State" +
+	'&calc_shipping_postcode=63366' +
+	"&calc_shipping_city=O'Fallon" +
+	'&woocommerce-shipping-calculator-nonce=abc123' +
+	'&_wp_http_referer=%2Fcart%2F' +
+	'&calc_shipping=x';
+
+const SHIPPING_FORM_ENCODED =
+	'calc_shipping_country=US' +
+	'&calc_shipping_state=O%27State' +
+	'&calc_shipping_postcode=63366' +
+	'&calc_shipping_city=O%27Fallon' +
+	'&woocommerce-shipping-calculator-nonce=abc123' +
+	'&_wp_http_referer=%2Fcart%2F' +
+	'&calc_shipping=x';
+
+const CART_FORM_SERIALIZED =
+	'cart%5Babc123%5D%5Bqty%5D=2' +
+	"&coupon_code=SAVE'10" +
+	"&order'note=Leave%20at%20O'Brien's%20door" +
+	'&reference=already%2527encoded' +
+	'&woocommerce-cart-nonce=abc123' +
+	'&_wp_http_referer=%2Fcart%2F';
+
+const CART_FORM_ENCODED =
+	'cart%5Babc123%5D%5Bqty%5D=2' +
+	'&coupon_code=SAVE%2710' +
+	'&order%27note=Leave%20at%20O%27Brien%27s%20door' +
+	'&reference=already%2527encoded' +
+	'&woocommerce-cart-nonce=abc123' +
+	'&_wp_http_referer=%2Fcart%2F';
+
+// quantity_update() appends a hidden update_cart input before serialize().
+const QUANTITY_FORM_SERIALIZED =
+	CART_FORM_SERIALIZED + '&update_cart=Update%20Cart';
+const QUANTITY_FORM_ENCODED = CART_FORM_ENCODED + '&update_cart=Update%20Cart';
+
+describe( 'cart.js request encoding', () => {
+	let capturedAjaxRequests;
+	let documentHandlers;
+	let findDocumentHandler;
+	let $cartForm;
+	let $shippingForm;
+	let $couponInput;
+	let $removeCouponLink;
+	let clickedSubmitName;
+
+	// Sentinels standing in for the DOM elements jQuery would hand to a
+	// delegated handler as `evt.currentTarget`.
+	const cartFormElement = { id: 'cart-form' };
+	const shippingFormElement = { id: 'shipping-form' };
+	const removeCouponElement = { id: 'remove-coupon' };
+
+	beforeEach( () => {
+		capturedAjaxRequests = [];
+		documentHandlers = [];
+		clickedSubmitName = null;
+
+		// Default mock for selectors the tests do not care about. Every method
+		// chains back to the mock so block()/unblock() and friends run through.
+		const createDefaultMock = () => {
+			const mock = {
+				length: 0,
+				addClass: jest.fn( () => mock ),
+				appendTo: jest.fn( () => mock ),
+				attr: jest.fn( () => mock ),
+				block: jest.fn( () => mock ),
+				closest: jest.fn( () => createDefaultMock() ),
+				each: jest.fn( () => mock ),
+				find: jest.fn( () => createDefaultMock() ),
+				hide: jest.fn( () => mock ),
+				is: jest.fn( () => false ),
+				on: jest.fn( () => mock ),
+				parents: jest.fn( () => createDefaultMock() ),
+				prop: jest.fn( () => mock ),
+				removeClass: jest.fn( () => mock ),
+				trigger: jest.fn( () => mock ),
+				unblock: jest.fn( () => mock ),
+				val: jest.fn(),
+			};
+			return mock;
+		};
+
+		// cart.js binds everything off $( document ). Direct registrations look
+		// like on( 'a b', handler ); delegated ones look like
+		// on( 'submit', selector, handler ). Store one entry per event name so a
+		// test can pick a handler by ( event, selector ).
+		const $document = {
+			on: jest.fn( ( events, selectorOrHandler, delegatedHandler ) => {
+				const isDelegated = typeof delegatedHandler === 'function';
+				events.split( ' ' ).forEach( ( event ) => {
+					documentHandlers.push( {
+						event,
+						selector: isDelegated ? selectorOrHandler : null,
+						handler: isDelegated ? delegatedHandler : selectorOrHandler,
+					} );
+				} );
+				return $document;
+			} ),
+		};
+
+		findDocumentHandler = ( event, selector = null ) => {
+			const entry = documentHandlers.find(
+				( candidate ) =>
+					candidate.event === event && candidate.selector === selector
+			);
+			if ( ! entry ) {
+				throw new Error(
+					'No ' + event + ' handler' + ( selector ? ' for ' + selector : '' )
+				);
+			}
+			return entry.handler;
+		};
+
+		const formAttributes = { method: 'post', action: CART_URL };
+
+		$cartForm = createDefaultMock();
+		$cartForm.length = 1;
+		$cartForm.attr = jest.fn( ( name ) => formAttributes[ name ] );
+		$cartForm.serialize = jest.fn( () => CART_FORM_SERIALIZED );
+		// cart_submit() bails unless the target is a form with cart contents.
+		$cartForm.is = jest.fn( ( selector ) => selector === 'form' );
+		$cartForm.find = jest.fn( ( selector ) =>
+			selector === '.woocommerce-cart-form__contents'
+				? { length: 1 }
+				: createDefaultMock()
+		);
+
+		$shippingForm = createDefaultMock();
+		$shippingForm.length = 1;
+		$shippingForm.attr = jest.fn( ( name ) => formAttributes[ name ] );
+		$shippingForm.serialize = jest.fn( () => SHIPPING_FORM_SERIALIZED );
+
+		$couponInput = createDefaultMock();
+		$couponInput.length = 1;
+		$couponInput.val = jest.fn( () => COUPON_CODE );
+
+		// remove_coupon_clicked() reads the code off data-coupon and blocks
+		// the closest .cart_totals wrapper.
+		$removeCouponLink = createDefaultMock();
+		$removeCouponLink.length = 1;
+		$removeCouponLink.attr = jest.fn( ( name ) =>
+			name === 'data-coupon' ? COUPON_CODE : undefined
+		);
+		$removeCouponLink.closest = jest.fn( () => createDefaultMock() );
+
+		// cart_submit() routes on which submit button was clicked.
+		const $clickedSubmit = {
+			is: jest.fn(
+				( selector ) =>
+					clickedSubmitName !== null &&
+					selector === ':input[name="' + clickedSubmitName + '"]'
+			),
+		};
+
+		const jQueryMock = jest.fn( ( arg ) => {
+			// Document ready: jQuery( function ( $ ) { ... } ).
+			if ( typeof arg === 'function' ) {
+				arg( jQueryMock );
+				return jQueryMock;
+			}
+			if ( arg === document ) {
+				return $document;
+			}
+			if (
+				arg === '.woocommerce-cart-form' ||
+				arg === cartFormElement ||
+				arg === $cartForm
+			) {
+				return $cartForm;
+			}
+			if ( arg === shippingFormElement || arg === $shippingForm ) {
+				return $shippingForm;
+			}
+			if ( arg === ':input[type=submit][clicked=true]' ) {
+				return $clickedSubmit;
+			}
+			if ( arg === '#coupon_code' ) {
+				return $couponInput;
+			}
+			if ( arg === removeCouponElement ) {
+				return $removeCouponLink;
+			}
+			return createDefaultMock();
+		} );
+		jQueryMock.ajax = jest.fn( ( options ) => {
+			capturedAjaxRequests.push( options );
+			return { abort: jest.fn() };
+		} );
+		// Mirrors jQuery 3 param(): encodeURIComponent per field, spaces as %20,
+		// apostrophes literal. encodeApostrophes() then turns `'` into %27.
+		jQueryMock.param = jest.fn( ( object ) => {
+			const parts = [];
+			const add = ( key, value ) => {
+				parts.push(
+					encodeURIComponent( key ) +
+						'=' +
+						encodeURIComponent(
+							value === null || value === undefined ? '' : value
+						)
+				);
+			};
+			const buildParams = ( prefix, value ) => {
+				if ( value !== null && typeof value === 'object' ) {
+					Object.keys( value ).forEach( ( nestedKey ) =>
+						buildParams( prefix + '[' + nestedKey + ']', value[ nestedKey ] )
+					);
+					return;
+				}
+				add( prefix, value );
+			};
+			Object.keys( object ).forEach( ( key ) =>
+				buildParams( key, object[ key ] )
+			);
+			return parts.join( '&' );
+		} );
+
+		global.window.jQuery = jQueryMock;
+		global.window.$ = jQueryMock;
+		global.jQuery = jQueryMock;
+		global.$ = jQueryMock;
+
+		global.window.wc_cart_params = {
+			ajax_url: '/wp-admin/admin-ajax.php',
+			wc_ajax_url: '/?wc-ajax=%%endpoint%%',
+			update_shipping_method_nonce: 'nonce',
+			apply_coupon_nonce: 'nonce',
+			remove_coupon_nonce: 'nonce',
+		};
+
+		// Requiring cart.js runs the jQuery wrapper and binds the handlers.
+		jest.resetModules();
+		require( '../cart' );
+	} );
+
+	afterEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	test( 'should encode apostrophes in shipping calculator data', () => {
+		const submit = findDocumentHandler(
+			'submit',
+			'form.woocommerce-shipping-calculator'
+		);
+		const evt = { preventDefault: jest.fn(), currentTarget: shippingFormElement };
+
+		submit( evt );
+
+		expect( evt.preventDefault ).toHaveBeenCalled();
+		expect( capturedAjaxRequests ).toHaveLength( 1 );
+
+		const request = capturedAjaxRequests[ 0 ];
+		expect( request.url ).toBe( CART_URL );
+		expect( request.data ).not.toContain( "'" );
+		expect( request.data ).toBe( SHIPPING_FORM_ENCODED );
+
+		// Every value decodes back to what the shopper typed.
+		const body = new URLSearchParams( request.data );
+		expect( body.get( 'calc_shipping_city' ) ).toBe( "O'Fallon" );
+		expect( body.get( 'calc_shipping_state' ) ).toBe( "O'State" );
+		expect( body.get( 'calc_shipping' ) ).toBe( 'x' );
+	} );
+
+	test( 'should encode apostrophes in update cart data', () => {
+		// update_cart() is reached through the wc_update_cart document event.
+		const updateCart = findDocumentHandler( 'wc_update_cart' );
+
+		updateCart( {} );
+
+		expect( capturedAjaxRequests ).toHaveLength( 1 );
+
+		const request = capturedAjaxRequests[ 0 ];
+		expect( request.url ).toBe( CART_URL );
+		expect( request.data ).not.toContain( "'" );
+		expect( request.data ).toBe( CART_FORM_ENCODED );
+
+		const body = new URLSearchParams( request.data );
+		expect( body.get( 'coupon_code' ) ).toBe( "SAVE'10" );
+		expect( body.get( 'cart[abc123][qty]' ) ).toBe( '2' );
+		expect( body.get( "order'note" ) ).toBe( "Leave at O'Brien's door" );
+		expect( body.get( 'reference' ) ).toBe( 'already%27encoded' );
+	} );
+
+	test( 'should encode apostrophes in quantity update data', () => {
+		// quantity_update() is reached through cart_submit() when the clicked
+		// submit button is the Update cart button.
+		clickedSubmitName = 'update_cart';
+		$cartForm.serialize.mockReturnValue( QUANTITY_FORM_SERIALIZED );
+		const submit = findDocumentHandler( 'submit', '.woocommerce-cart-form' );
+		const evt = { preventDefault: jest.fn(), currentTarget: cartFormElement };
+
+		submit( evt );
+
+		// preventDefault() proves cart_submit() took the quantity_update() branch.
+		expect( evt.preventDefault ).toHaveBeenCalled();
+		expect( capturedAjaxRequests ).toHaveLength( 1 );
+
+		const request = capturedAjaxRequests[ 0 ];
+		expect( request.url ).toBe( CART_URL );
+		expect( request.data ).not.toContain( "'" );
+		expect( request.data ).toBe( QUANTITY_FORM_ENCODED );
+		const body = new URLSearchParams( request.data );
+		expect( body.get( 'coupon_code' ) ).toBe( "SAVE'10" );
+		expect( body.get( 'update_cart' ) ).toBe( 'Update Cart' );
+	} );
+
+	test( 'should encode apostrophes in apply coupon data', () => {
+		clickedSubmitName = 'apply_coupon';
+		const submit = findDocumentHandler( 'submit', '.woocommerce-cart-form' );
+		const evt = { preventDefault: jest.fn(), currentTarget: cartFormElement };
+
+		submit( evt );
+
+		expect( evt.preventDefault ).toHaveBeenCalled();
+
+		const request = capturedAjaxRequests.find( ( options ) =>
+			options.url.includes( 'apply_coupon' )
+		);
+		expect( request ).toBeDefined();
+		expect( request.data ).not.toContain( "'" );
+
+		const body = new URLSearchParams( request.data );
+		expect( body.get( 'coupon_code' ) ).toBe( COUPON_CODE );
+		expect( body.get( 'security' ) ).toBe( 'nonce' );
+	} );
+
+	test( 'should encode apostrophes in remove coupon data', () => {
+		const click = findDocumentHandler( 'click', 'a.woocommerce-remove-coupon' );
+		const evt = {
+			preventDefault: jest.fn(),
+			currentTarget: removeCouponElement,
+		};
+
+		click( evt );
+
+		expect( evt.preventDefault ).toHaveBeenCalled();
+
+		const request = capturedAjaxRequests.find( ( options ) =>
+			options.url.includes( 'remove_coupon' )
+		);
+		expect( request ).toBeDefined();
+		expect( request.data ).not.toContain( "'" );
+		expect( new URLSearchParams( request.data ).get( 'coupon' ) ).toBe(
+			COUPON_CODE
+		);
+	} );
+} );