Commit 975cd3b352b for woocommerce

commit 975cd3b352bc4773ffe3fc85b5c68c4e68ec1fdf
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 7 15:31:54 2026 +0300

    Fix apostrophe encoding in classic checkout requests (#68365)

    * fix(checkout): Encode apostrophes in request data

    Classic checkout serialized form fields with jQuery, which leaves apostrophes unescaped in outgoing request bodies. Strict intermediaries can reject those requests before WooCommerce receives them.

    Post-process the existing serialized bytes at both checkout request boundaries so apostrophes use %27 while all other jQuery serialization behavior remains unchanged. Cover field names, values, arrays, separators, and existing escapes with exact-byte regression tests.

    Refs #40791

    * chore: Add checkout apostrophe encoding changelog

    Document the classic checkout request compatibility fix for the next WooCommerce release.

    Refs #40791

    * refactor: Replace apostrophe-encoding regex with split/join

    The checkout form serializer encoded apostrophes with a global regex,
    `replace( /'/g, '%27' )`.

    The repo discourages regular expressions where a readable built-in
    alternative exists (`.cursor/rules/avoid-regex.mdc`, `alwaysApply`
    over `**/*.js`). A fixed single-character literal is exactly the case
    that rule targets, so the regex was not warranted.

    `split( "'" ).join( '%27' )` produces identical output. `replaceAll`
    would read better still, but it is not an option here: the classic
    assets bundle is built with copy/concat/uglify and no Babel pass, so
    the source ships to browsers untranspiled and unpolyfilled, and an
    ES2021 method would break older ones.

    No behavior change; the two apostrophe-encoding tests in
    `checkout-place-order-api.js` cover the swap.

    Note that `admin/wc-enhanced-select.js` still uses the regex form for
    the same encoding. Aligning it is out of scope for this branch, so the
    two spellings coexist for now.

    Refs #40791

    * Encode apostrophes in whole classic checkout request bodies

    Checkout requests encoded apostrophes only inside the serialized
    post_data field, so the address fields that update_order_review sends
    alongside it - city, address, address_2, state and the s_* copies -
    still reached the server with literal apostrophes. WC_AJAX reads those
    straight into the customer object, so a shopper in a place like
    O'Fallon could still trip the WAF rule this fix is meant to avoid. The
    apply_coupon and remove_coupon requests carried the same exposure on
    billing_email, the exact field from the original report.

    Move the encoding to the request body itself. A single
    encodeApostrophes() helper now wraps every outbound body in the file,
    so no field can drift out of coverage as the payload grows.

    Encoding the body rather than one field also keeps post_data
    byte-identical to what serialize() produced, so third-party callbacks
    that read the raw string on woocommerce_checkout_update_order_review
    see exactly what they saw before.

    * Scope Jest fake timers to the checkout serialization tests

    The fake timer setup sat in the outer beforeEach, so it also wrapped
    the six validation tests that never touch a timer. Only the
    serialization tests need it, to flush the 5 ms setTimeout in
    update_checkout.

    Move the timer hooks into that describe block so the coupling stays
    local to the tests that depend on it.

    * Replace regex with split/join in the test param serializer

    The test's $.param port converted %20 to + with a regular expression.
    The repo's avoid-regex guideline asks for a built-in alternative
    wherever one exists, and split().join() is equivalent for a literal
    substring.

    It also matches the production helper this PR adds, which uses
    split().join() because client/legacy has no Babel pass.

    * Add Jest coverage for the coupon request bodies

    encodeApostrophes() wraps four request bodies in checkout.js, but the
    suite only covered two of them - update_order_review and the final
    checkout submit. Dropping the helper from either coupon call left the
    tests green, so half the call sites had no regression guard.

    Add one test per coupon endpoint, asserting the body carries no literal
    apostrophe and that the values round-trip.

    Reaching those handlers needed three changes to the mock. The coupon
    form is bound directly at init(), and the selector dispatch returned a
    fresh mock per lookup, so the submit registration could not be
    retrieved afterwards; it now gets a stable mock. Delegated document.body
    handlers were stored by event name alone, which collided the four click
    registrations and stored the selector in place of the handler; storage
    is now selector-aware. The checkout form's find dispatch gained the
    billing_email entry apply_coupon reads.

    No production code changes.

    ---------

    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-40791-checkout-apostrophe-encoding b/plugins/woocommerce/changelog/fix-40791-checkout-apostrophe-encoding
new file mode 100644
index 00000000000..bbc1111beee
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-40791-checkout-apostrophe-encoding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Encode apostrophes in classic checkout request data.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/checkout.js b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
index 88c6e1607f5..8b59f069c3d 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/checkout.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
@@ -5,6 +5,19 @@ jQuery( function ( $ ) {
 		return false;
 	}

+	/**
+	 * 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' );
+	}
+
 	$.blockUI.defaults.overlayCSS.cursor = 'default';

 	/**
@@ -717,7 +730,7 @@ jQuery( function ( $ ) {
 				url: wc_checkout_params.wc_ajax_url
 					.toString()
 					.replace( '%%endpoint%%', 'update_order_review' ),
-				data: data,
+				data: encodeApostrophes( $.param( data ) ),
 				success: function ( data ) {
 					// Reload the page if requested
 					if ( data && true === data.reload ) {
@@ -961,7 +974,7 @@ jQuery( function ( $ ) {
 				$.ajax( {
 					type: 'POST',
 					url: wc_checkout_params.checkout_url,
-					data: $form.serialize(),
+					data: encodeApostrophes( $form.serialize() ),
 					dataType: 'json',
 					success: function ( result ) {
 						// Detach the unload handler that prevents a reload / redirect
@@ -1267,7 +1280,7 @@ jQuery( function ( $ ) {
 				url: wc_checkout_params.wc_ajax_url
 					.toString()
 					.replace( '%%endpoint%%', 'apply_coupon' ),
-				data: data,
+				data: encodeApostrophes( $.param( data ) ),
 				success: function ( response ) {
 					$(
 						'.woocommerce-error, .woocommerce-message, .is-error, .is-success, .checkout-inline-error-message'
@@ -1341,7 +1354,7 @@ jQuery( function ( $ ) {
 				url: wc_checkout_params.wc_ajax_url
 					.toString()
 					.replace( '%%endpoint%%', 'remove_coupon' ),
-				data: data,
+				data: encodeApostrophes( $.param( data ) ),
 				success: function ( code ) {
 					$(
 						'.woocommerce-error, .woocommerce-message, .is-error, .is-success'
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
index f39a76a85de..399948a463a 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
@@ -2,18 +2,39 @@
  * @jest-environment jest-fixed-jsdom
  */

+// Apostrophe-bearing coupon fixtures. `billing_email` is the field from the
+// original report, and the coupon endpoints post it from the checkout page.
+const COUPON_CODE = "SAVE'10";
+const BILLING_EMAIL = "o'brien@example.com";
+
 describe( 'createCheckoutPlaceOrderApi', () => {
 	let $form;
+	let $couponForm;
+	let $removeCouponLink;
 	let $termsCheckbox;
 	let $termsRow;
 	let capturedApi;
+	let capturedAjaxRequests;
+	let jQueryMock;
+	let mockBody;
+	let serializedCheckoutData;
 	// Set the number of invalid `.form-row` elements that are hidden (e.g. the
 	// collapsed "Ship to a different address?" shipping fields). These must never
 	// block submission, so `validate()` should only count visible invalid fields.
 	let setHiddenInvalidCount;
+	// Fire a handler that checkout.js delegated off document.body, with `this`
+	// bound to the element that would have matched the selector.
+	let triggerDelegatedBodyEvent;

 	beforeEach( () => {
 		capturedApi = null;
+		capturedAjaxRequests = [];
+		serializedCheckoutData =
+			"billing_email=shopper'o%40example.test" +
+			'&company=Rock+%26+Roll' +
+			'&items%5B%5D=one' +
+			"&delivery'note=Recipient's+door" +
+			'&reference=already%27encoded';
 		let hiddenInvalidCount = 0;
 		setHiddenInvalidCount = ( count ) => {
 			hiddenInvalidCount = count;
@@ -61,6 +82,10 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 		};

 		$form = {
+			addClass: jest.fn( () => $form ),
+			block: jest.fn( () => $form ),
+			data: jest.fn(),
+			is: jest.fn( () => false ),
 			length: 1,
 			find: jest.fn( ( selector ) => {
 				if ( selector === 'input[name="terms"]:visible' ) {
@@ -99,9 +124,15 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 				if ( selector === 'input[name="payment_method"]:checked' ) {
 					return { val: jest.fn( () => 'test-gateway' ) };
 				}
+				if ( selector === 'input[name="billing_email"]' ) {
+					// apply_coupon reads this off the checkout form.
+					return { val: jest.fn( () => BILLING_EMAIL ) };
+				}
 				return { length: 0, trigger: jest.fn() };
 			} ),
+			serialize: jest.fn( () => serializedCheckoutData ),
 			trigger: jest.fn(),
+			triggerHandler: jest.fn( () => true ),
 		};

 		// Add methods to $form for checkout.js initialization
@@ -162,26 +193,90 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			return mock;
 		};

-		// Simple event system for document.body to enable event-based API capture
+		// Simple event system for document.body to enable event-based API capture.
+		// Registrations come in two shapes: direct `on( event, handler )` and
+		// delegated `on( event, selector, handler )`. Several delegated handlers
+		// share the 'click' event, so the selector has to be stored to tell them
+		// apart, and only direct handlers respond to trigger().
 		const bodyEventHandlers = {};
-		const mockBody = {
-			on: jest.fn( ( event, handler ) => {
+		mockBody = {
+			on: jest.fn( ( event, selectorOrHandler, delegatedHandler ) => {
+				const isDelegated = typeof delegatedHandler === 'function';
 				if ( ! bodyEventHandlers[ event ] ) {
 					bodyEventHandlers[ event ] = [];
 				}
-				bodyEventHandlers[ event ].push( handler );
+				bodyEventHandlers[ event ].push( {
+					selector: isDelegated ? selectorOrHandler : null,
+					handler: isDelegated ? delegatedHandler : selectorOrHandler,
+				} );
 				return mockBody;
 			} ),
 			trigger: jest.fn( ( event, args ) => {
-				const handlers = bodyEventHandlers[ event ] || [];
-				handlers.forEach( ( handler ) => handler( {}, ...( args || [] ) ) );
+				( bodyEventHandlers[ event ] || [] )
+					.filter( ( entry ) => entry.selector === null )
+					.forEach( ( entry ) => entry.handler( {}, ...( args || [] ) ) );
 				return mockBody;
 			} ),
 			hasClass: jest.fn( () => false ),
 		};

+		triggerDelegatedBodyEvent = ( event, selector, element ) => {
+			const entry = ( bodyEventHandlers[ event ] || [] ).find(
+				( candidate ) => candidate.selector === selector
+			);
+			if ( ! entry ) {
+				throw new Error(
+					'No delegated ' + event + ' handler for ' + selector
+				);
+			}
+			entry.handler.call( element, { preventDefault: jest.fn() } );
+		};
+
+		// update_order_review sends these as siblings of post_data, so they have
+		// to carry apostrophes for the test to prove the whole body is encoded.
+		const addressFieldValues = {
+			'#billing_country': 'US',
+			'#billing_state': "O'State",
+			':input#billing_postcode': '12345',
+			'#billing_city': "O'Fallon",
+			':input#billing_address_1': "123 O'Brien Ave",
+			':input#billing_address_2': "Apt O'2",
+		};
+
+		// The coupon form is bound directly at init(), so it needs a stable mock
+		// rather than a fresh default one per lookup — otherwise the submit
+		// registration can't be retrieved afterwards.
+		$couponForm = {
+			length: 1,
+			hide: jest.fn( () => $couponForm ),
+			on: jest.fn( () => $couponForm ),
+			is: jest.fn( () => false ),
+			addClass: jest.fn( () => $couponForm ),
+			removeClass: jest.fn( () => $couponForm ),
+			block: jest.fn( () => $couponForm ),
+			unblock: jest.fn( () => $couponForm ),
+			slideUp: jest.fn( () => $couponForm ),
+			before: jest.fn( () => $couponForm ),
+			find: jest.fn( ( selector ) => {
+				if ( selector === 'input[name="coupon_code"]' ) {
+					return { val: jest.fn( () => COUPON_CODE ) };
+				}
+				return createDefaultMock();
+			} ),
+		};
+
+		// The clicked ".woocommerce-remove-coupon" element. remove_coupon reads
+		// the code off it with data( 'coupon' ).
+		$removeCouponLink = {
+			length: 1,
+			parents: jest.fn( () => createDefaultMock() ),
+			data: jest.fn( ( key ) =>
+				key === 'coupon' ? COUPON_CODE : undefined
+			),
+		};
+
 		// Mock jQuery - needs to handle document ready pattern: jQuery(function($) { ... })
-		const jQueryMock = jest.fn( ( selectorOrCallback ) => {
+		jQueryMock = jest.fn( ( selectorOrCallback ) => {
 			// Handle document ready: jQuery(function($) { ... })
 			if ( typeof selectorOrCallback === 'function' ) {
 				// Execute immediately with jQuery mock as argument
@@ -191,6 +286,18 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			if ( selectorOrCallback === 'form.checkout' ) {
 				return $form;
 			}
+			if ( selectorOrCallback === $form ) {
+				return $form;
+			}
+			if (
+				selectorOrCallback === 'form.checkout_coupon' ||
+				selectorOrCallback === $couponForm
+			) {
+				return $couponForm;
+			}
+			if ( selectorOrCallback === $removeCouponLink ) {
+				return $removeCouponLink;
+			}
 			if ( selectorOrCallback === '#order_review' ) {
 				return { length: 0, on: jest.fn(), attr: jest.fn(), find: jest.fn( () => ( { length: 0, val: jest.fn() } ) ) };
 			}
@@ -200,10 +307,47 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			if ( selectorOrCallback === document.body ) {
 				return mockBody;
 			}
+			if ( addressFieldValues[ selectorOrCallback ] !== undefined ) {
+				const addressMock = createDefaultMock();
+				addressMock.val = jest.fn(
+					() => addressFieldValues[ selectorOrCallback ]
+				);
+				return addressMock;
+			}
 			// Return a default mock for any other selector
 			return createDefaultMock();
 		} );
 		jQueryMock.blockUI = { defaults: { overlayCSS: {} } };
+		jQueryMock.ajax = jest.fn( ( options ) => {
+			capturedAjaxRequests.push( options );
+			return { abort: jest.fn() };
+		} );
+		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( ( key ) =>
+						buildParams( prefix + '[' + key + ']', value[ key ] )
+					);
+					return;
+				}
+				add( prefix, value );
+			};
+			Object.keys( object ).forEach( ( key ) =>
+				buildParams( key, object[ key ] )
+			);
+			return parts.join( '&' ).split( '%20' ).join( '+' );
+		} );
+		jQueryMock.ajaxSetup = jest.fn();

 		global.window.jQuery = jQueryMock;
 		global.window.$ = jQueryMock;
@@ -211,7 +355,14 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 		global.$ = jQueryMock;

 		global.window.wc_checkout_params = {
+			checkout_url: '/?wc-ajax=checkout',
 			gateways_with_custom_place_order_button: [ 'test-gateway' ],
+			is_checkout: '0',
+			option_guest_checkout: 'no',
+			update_order_review_nonce: 'nonce',
+			apply_coupon_nonce: 'nonce',
+			remove_coupon_nonce: 'nonce',
+			wc_ajax_url: '/?wc-ajax=%%endpoint%%',
 		};

 		global.window.wc = {
@@ -321,4 +472,106 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			expect( $form.find ).not.toHaveBeenCalledWith( '.woocommerce-invalid' );
 		} );
 	} );
+
+	describe( 'Checkout form serialization', () => {
+		beforeEach( () => {
+			jest.useFakeTimers();
+		} );
+
+		afterEach( () => {
+			jest.clearAllTimers();
+			jest.useRealTimers();
+		} );
+
+		const expectedSerializedData =
+			'billing_email=shopper%27o%40example.test' +
+			'&company=Rock+%26+Roll' +
+			'&items%5B%5D=one' +
+			'&delivery%27note=Recipient%27s+door' +
+			'&reference=already%27encoded';
+
+		test( 'should encode apostrophes in update order review data', () => {
+			mockBody.trigger( 'update_checkout', [
+				{ update_shipping_method: false },
+			] );
+			jest.runOnlyPendingTimers();
+
+			const request = capturedAjaxRequests.find( ( options ) =>
+				options.url.includes( 'update_order_review' )
+			);
+
+			expect( request ).toBeDefined();
+			expect( request.data ).not.toContain( "'" );
+
+			// Address fields travel outside post_data and must be encoded too.
+			expect( request.data ).toContain( 'city=O%27Fallon' );
+			expect( request.data ).toContain( 'address=123+O%27Brien+Ave' );
+			expect( request.data ).toContain( 'state=O%27State' );
+
+			// The whole body is encoded, so post_data survives the outer layer
+			// byte-for-byte and raw-string consumers see what serialize() produced.
+			const postData = new URLSearchParams( request.data ).get(
+				'post_data'
+			);
+			expect( postData ).toBe( serializedCheckoutData );
+		} );
+
+		test( 'should encode apostrophes in final checkout data', () => {
+			const submitRegistration = $form.on.mock.calls.find(
+				( call ) => call[ 0 ] === 'submit'
+			);
+			expect( submitRegistration ).toBeDefined();
+
+			submitRegistration[ 1 ].call( $form );
+			const request = capturedAjaxRequests.find(
+				( options ) => options.url === '/?wc-ajax=checkout'
+			);
+
+			expect( request ).toBeDefined();
+			expect( request.data ).toBe( expectedSerializedData );
+		} );
+	} );
+
+	// The coupon endpoints are the fourth and third of the four request bodies
+	// routed through encodeApostrophes(). No fake timers here: neither handler
+	// schedules one unless its success callback runs, which these never do.
+	describe( 'Coupon request serialization', () => {
+		test( 'should encode apostrophes in apply coupon data', () => {
+			const submitRegistration = $couponForm.on.mock.calls.find(
+				( call ) => call[ 0 ] === 'submit'
+			);
+			expect( submitRegistration ).toBeDefined();
+
+			submitRegistration[ 1 ]( { currentTarget: $couponForm } );
+
+			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( 'billing_email' ) ).toBe( BILLING_EMAIL );
+		} );
+
+		test( 'should encode apostrophes in remove coupon data', () => {
+			triggerDelegatedBodyEvent(
+				'click',
+				'.woocommerce-remove-coupon',
+				$removeCouponLink
+			);
+
+			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
+			);
+		} );
+	} );
 } );