Commit 3f9bc8a3e93 for woocommerce

commit 3f9bc8a3e93a10146b8e7d7ee91706bb5c7ba038
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Oct 16 15:56:59 2026 +0300

    Send classic cart and checkout requests with browser form encoding (#69679)

    * fix(checkout): Encode apostrophes through a jQuery ajax prefilter

    Some web application firewalls reject request bodies that carry a
    literal apostrophe, so a shopper whose email or city contains one
    cannot get past the classic checkout's update_order_review call.
    PR #68365 (11.2.0) fixed that by serializing the data with $.param()
    and encoding the string before handing it to $.ajax(). That changed
    two things jQuery's own serialization used to guarantee: undefined
    values (fields removed from the form by a filter) became empty keys
    that cleared the customer's country, state and address on every
    refresh, and prefilters that read originalOptions.data as an object
    (HurryTimer, Global Seller Services) got a string. The checkout half
    was reverted in #69644; cart.js still carried the same shape from
    #68556.

    Pass the data to $.ajax() as before and register one ajaxPrefilter in
    woocommerce.js, a dependency of both wc-checkout and wc-cart, that
    encodes apostrophes in the already-serialized body of requests that
    opt in with the wc_encode_apostrophes option. jQuery serializes the
    request first, so removed fields stay out of the body and other
    prefilters see the original object. Only WooCommerce's own cart and
    checkout calls set the option; third-party scripts can set it on
    their own requests. PHP decodes the body once, so $_POST is unchanged.

    Jest covers the prefilter, the opt-in on every call site, and the
    object-shaped bodies. Validated on a dev store against HurryTimer,
    FOX Currency Switcher, the Global Seller Services prefilter, a global
    beforeSend and the field-removal snippets from the 11.2.0 reports.

    Refs #40791

    * Add changelog entry for the apostrophe encoding prefilter

    * test(checkout): Cover the apostrophe prefilter with real jQuery

    The prefilter design depends on jQuery's request order: copy the
    options (dropping undefined fields), serialize, then run prefilters.
    The existing Jest tests mock jQuery, so that order was only checked by
    hand. Add jquery 3.7.1 (the version WordPress ships) as a dev
    dependency of the classic assets package and a test that runs
    woocommerce.js through the real pipeline with a fake XMLHttpRequest:
    removed fields stay out of the body, nested data survives, apostrophes
    reach the wire as %27, a later prefilter still gets the original object,
    a global beforeSend and an ajaxSend handler keep working, and a
    beforeSend that returns false still aborts.

    Also assert the update_shipping_method request in the cart suite, which
    was the one request site without a test, and say in the woocommerce.js
    docblock that later prefilters and beforeSend handlers see the encoded
    string in options.data.

    Refs #40791

    * test(checkout): Shorten the shipping method selector line in the cart test

    The cart test compared the chosen shipping methods selector inline,
    which left a 148-character line. The branch lint job enforces ESLint's
    140-character max-len, so the Lint check failed on the PR.

    Move the selector into a top-level constant next to the other
    fixtures, split across short string pieces. The test logic is
    unchanged.

    Refs #40791

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * docs(checkout): Say which prefilters see the encoded apostrophes

    The prefilter docblock said every prefilter registered after it sees
    the encoded body. In jQuery 3.7.1 that is not quite true: prefilters
    for the request's own data type (json on checkout submit, html on cart
    requests) run before the '*' list, and a '*' prefilter added with the
    '+' flag is put in front of ours. Both still see the raw apostrophes,
    as on 11.2.1, so only the comment was wrong.

    The pipeline test also claimed plugin scripts always load after
    woocommerce.js. The script loads with the default defer strategy, so a
    plugin script in the head can register first. The comment now only
    says the test prefilter is added after ours.

    Refs #40791

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(checkout): Drop the beforeSend abort test that only covers jQuery

    The test set a global beforeSend that returns false and asserted that
    nothing reached the transport. jQuery runs beforeSend after every
    prefilter and the prefilter never touches the abort path, so the test
    kept passing with the prefilter's encoding removed. It could only fail
    on a jQuery bug. The abort case stays covered by the store validation
    recorded in the PR.

    Refs #40791

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * fix(classic-assets): send cart and checkout requests with browser form encoding

    The classic cart and checkout send their AJAX bodies through jQuery,
    which serializes with encodeURIComponent() and leaves !'()~ literal. A
    browser submitting the same form without JavaScript percent-encodes
    those five characters, and some WAF rules (Sucuri in #40791) reject the
    literal apostrophe. The first fix (#68365, #68556) serialized in the
    callers and shipped in 11.2.0; it turned removed address fields into
    empty values and handed prefilters a string, so it was reverted
    (#69644, #69696). The prefilter re-land on this branch kept
    originalOptions.data intact but still only replaced the apostrophe,
    assumed jQuery serializes before prefilters run, and called
    jQuery.ajaxPrefilter() unguarded.

    Order attribution puts "(none)" in every update_order_review body, so a
    rule on parentheses blocks the classic checkout at page load; encoding
    the apostrophe alone leaves that in place. jQuery 4.0.0 runs prefilters
    before jQuery.param(); WordPress core still ships 3.7.1 and is
    evaluating 4.x, and once it moves, object data would reach the
    prefilter untouched and six of the ten requests would go out unencoded
    with no test to notice. A slim jQuery build has no ajaxPrefilter(), so
    the unguarded call threw and took the rest of woocommerce.js with it on
    every page. A json prefilter that rewrote the body before ours would
    have had its JSON mangled.

    The prefilter now matches the browser's form serializer for the whole
    set (!'()~ become %21 %27 %28 %29 %7E), only when the body is
    application/x-www-form-urlencoded and processData is on, serializes
    plain object data itself from the copy jQuery already made (so removed
    fields stay dropped on jQuery 4 too), and registers only when
    jQuery.ajaxPrefilter exists. The opt-in option is renamed from
    wc_encode_apostrophes to wc_form_encoding to describe that contract;
    the old name never shipped. The real-jQuery pipeline test now runs on
    3.7.1 and 4.0.0 (devDependency alias jquery4) and asserts the body
    equals URLSearchParams output.

    Refs #40791
    Refs #68395

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(classic-assets): make each prefilter guard fail its own test

    Review of the form encoding prefilter found that the two "non-form body"
    unit tests set both processData: false and a non-form content type, so
    either guard could be removed with every test still passing, and that
    nothing exercised the traditional pass-through or array data on the
    jQuery 4 path, where the prefilter serializes the data itself.

    Split the unit cases so each one differs from a WooCommerce request in
    one setting only, and add two pipeline cases on real jQuery: traditional
    array serialization (ids=1&ids=2) and serializeArray()-shaped data.
    Each of the four tests now fails only when its own code path is removed.
    Drop the unit test that passed originalOptions to a prefilter that never
    reads it; the pipeline suite checks that contract with real jQuery on
    both versions.

    Refs #40791

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(classic-assets): wrap a long expected body in the prefilter pipeline test

    The plugin-level ESLint config CI runs on changed files enforces
    max-len 140, and the serialized-body expectation ran to 169 characters.
    Split the string literal across two lines.

    Refs #40791

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-40791-apostrophe-ajax-prefilter b/plugins/woocommerce/changelog/fix-40791-apostrophe-ajax-prefilter
new file mode 100644
index 00000000000..0ef6cdbab04
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-40791-apostrophe-ajax-prefilter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send classic cart and checkout requests encoded like a browser form post, so security rules that block apostrophes or parentheses no longer stop them.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/cart.js b/plugins/woocommerce/client/legacy/js/frontend/cart.js
index f704eeaa5c3..24a799d281f 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/cart.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/cart.js
@@ -333,6 +333,7 @@ jQuery( function ( $ ) {
 				type: 'post',
 				url: get_url( 'update_shipping_method' ),
 				data: data,
+				wc_form_encoding: true,
 				dataType: 'html',
 				success: function ( response ) {
 					update_cart_totals_div( response );
@@ -375,6 +376,7 @@ jQuery( function ( $ ) {
 				type: $form.attr( 'method' ),
 				url: $form.attr( 'action' ),
 				data: $form.serialize(),
+				wc_form_encoding: true,
 				dataType: 'html',
 				success: function ( response ) {
 					update_wc_div( response );
@@ -492,6 +494,7 @@ jQuery( function ( $ ) {
 				type: $form.attr( 'method' ),
 				url: $form.attr( 'action' ),
 				data: $form.serialize(),
+				wc_form_encoding: true,
 				dataType: 'html',
 				success: function ( response ) {
 					update_wc_div( response, preserve_notices );
@@ -625,6 +628,7 @@ jQuery( function ( $ ) {
 				type: 'POST',
 				url: get_url( 'apply_coupon' ),
 				data: data,
+				wc_form_encoding: true,
 				dataType: 'html',
 				success: function ( response ) {
 					$(
@@ -683,6 +687,7 @@ jQuery( function ( $ ) {
 				type: 'POST',
 				url: get_url( 'remove_coupon' ),
 				data: data,
+				wc_form_encoding: true,
 				dataType: 'html',
 				success: function ( response ) {
 					$(
@@ -742,6 +747,7 @@ jQuery( function ( $ ) {
 				type: $form.attr( 'method' ),
 				url: $form.attr( 'action' ),
 				data: $form.serialize(),
+				wc_form_encoding: true,
 				dataType: 'html',
 				success: function ( response ) {
 					update_wc_div( response );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/checkout.js b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
index 96b8ebee1fc..3cb8662b87c 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/checkout.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
@@ -740,6 +740,7 @@ jQuery( function ( $ ) {
 					.toString()
 					.replace( '%%endpoint%%', 'update_order_review' ),
 				data: data,
+				wc_form_encoding: true,
 				success: function ( data ) {
 					// Reload the page if requested
 					if ( data && true === data.reload ) {
@@ -1018,6 +1019,7 @@ jQuery( function ( $ ) {
 					type: 'POST',
 					url: wc_checkout_params.checkout_url,
 					data: $form.serialize(),
+					wc_form_encoding: true,
 					dataType: 'json',
 					success: function ( result ) {
 						// Detach the unload handler that prevents a reload / redirect
@@ -1324,6 +1326,7 @@ jQuery( function ( $ ) {
 					.toString()
 					.replace( '%%endpoint%%', 'apply_coupon' ),
 				data: data,
+				wc_form_encoding: true,
 				success: function ( response ) {
 					$(
 						'.woocommerce-error, .woocommerce-message, .is-error, .is-success, .checkout-inline-error-message'
@@ -1398,6 +1401,7 @@ jQuery( function ( $ ) {
 					.toString()
 					.replace( '%%endpoint%%', 'remove_coupon' ),
 				data: data,
+				wc_form_encoding: true,
 				success: function ( code ) {
 					$(
 						'.woocommerce-error, .woocommerce-message, .is-error, .is-success'
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..6a1b0256fc6
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/cart.js
@@ -0,0 +1,351 @@
+/**
+ * @jest-environment jest-fixed-jsdom
+ */
+
+// Fixtures match jQuery 3 serialize(): encodeURIComponent per field, spaces as
+// %20, apostrophes literal. cart.js hands this to $.ajax() untouched and opts in
+// to the apostrophe encoding that the woocommerce.js prefilter applies.
+const CART_URL = 'https://example.test/cart/';
+const COUPON_CODE = "SAVE'10";
+
+// Matches the selector cart.js uses to collect the chosen shipping methods.
+const CHOSEN_SHIPPING_METHODS_SELECTOR =
+	'select.shipping_method, ' +
+	':input[name^=shipping_method][type=radio]:checked, ' +
+	':input[name^=shipping_method][type=hidden]';
+
+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 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';
+
+// quantity_update() appends a hidden update_cart input before serialize().
+const QUANTITY_FORM_SERIALIZED =
+	CART_FORM_SERIALIZED + '&update_cart=Update%20Cart';
+
+describe( 'cart.js request data', () => {
+	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' };
+	const shippingMethodElement = { id: 'shipping-method' };
+
+	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;
+			}
+			if ( arg === CHOSEN_SHIPPING_METHODS_SELECTOR ) {
+				const $methods = createDefaultMock();
+				$methods.each = jest.fn( ( callback ) => {
+					callback.call( shippingMethodElement, 0, shippingMethodElement );
+					return $methods;
+				} );
+				return $methods;
+			}
+			if ( arg === shippingMethodElement ) {
+				const $method = createDefaultMock();
+				$method.data = jest.fn( () => 0 );
+				$method.val = jest.fn( () => "flat_rate:O'Fallon" );
+				return $method;
+			}
+			return createDefaultMock();
+		} );
+		jQueryMock.ajax = jest.fn( ( options ) => {
+			capturedAjaxRequests.push( options );
+			return { abort: jest.fn() };
+		} );
+		jQueryMock.param = jest.fn();
+
+		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();
+	} );
+
+	// Every request site passes the data to $.ajax() as jQuery would get it from
+	// the shopper (the serialized form, or the data object) and sets the
+	// wc_form_encoding option, so the woocommerce.js prefilter encodes the body
+	// like a browser form post. Passing objects keeps undefined fields out of
+	// the body and lets other prefilters read originalOptions.data.
+	test( 'should send the serialized shipping calculator form with the encoding opt-in', () => {
+		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 ).toBe( SHIPPING_FORM_SERIALIZED );
+		expect( request.wc_form_encoding ).toBe( true );
+		expect( global.jQuery.param ).not.toHaveBeenCalled();
+	} );
+
+	test( 'should send the serialized cart form with the encoding opt-in', () => {
+		// 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 ).toBe( CART_FORM_SERIALIZED );
+		expect( request.wc_form_encoding ).toBe( true );
+	} );
+
+	test( 'should send the serialized quantity update form with the encoding opt-in', () => {
+		// 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 ).toBe( QUANTITY_FORM_SERIALIZED );
+		expect( request.wc_form_encoding ).toBe( true );
+	} );
+
+	test( 'should send the chosen shipping method as an object with the encoding opt-in', () => {
+		const change = findDocumentHandler(
+			'change',
+			'select.shipping_method, :input[name^=shipping_method]'
+		);
+
+		change( { currentTarget: shippingMethodElement } );
+
+		const request = capturedAjaxRequests.find( ( options ) =>
+			options.url.includes( 'update_shipping_method' )
+		);
+		expect( request ).toBeDefined();
+		expect( typeof request.data ).toBe( 'object' );
+		expect( request.data ).toEqual( {
+			security: 'nonce',
+			shipping_method: { 0: "flat_rate:O'Fallon" },
+		} );
+		expect( request.wc_form_encoding ).toBe( true );
+	} );
+
+	test( 'should send apply coupon data as an object with the encoding opt-in', () => {
+		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( typeof request.data ).toBe( 'object' );
+		expect( request.data ).toEqual( {
+			security: 'nonce',
+			coupon_code: COUPON_CODE,
+		} );
+		expect( request.wc_form_encoding ).toBe( true );
+		expect( global.jQuery.param ).not.toHaveBeenCalled();
+	} );
+
+	test( 'should send remove coupon data as an object with the encoding opt-in', () => {
+		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( typeof request.data ).toBe( 'object' );
+		expect( request.data ).toEqual( {
+			security: 'nonce',
+			coupon: COUPON_CODE,
+		} );
+		expect( request.wc_form_encoding ).toBe( true );
+	} );
+} );
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 e88fe4571d3..e6d196151af 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
@@ -497,7 +497,8 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 	} );

 	// Request bodies keep the 11.1 shape: data objects for the AJAX endpoints, so
-	// $.ajax() drops undefined (removed) fields and prefilters see an object.
+	// $.ajax() drops undefined (removed) fields and prefilters see an object. Each
+	// request opts in to the woocommerce.js apostrophe-encoding prefilter instead.
 	describe( 'Checkout request data', () => {
 		beforeEach( () => {
 			jest.useFakeTimers();
@@ -528,6 +529,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			);
 			expect( request.data.country ).toBeUndefined();
 			expect( request.data.state ).toBeUndefined();
+			expect( request.wc_form_encoding ).toBe( true );
 		} );

 		test( 'should send the serialized form when placing an order', () => {
@@ -544,6 +546,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {

 			expect( request ).toBeDefined();
 			expect( request.data ).toBe( serializedCheckoutData );
+			expect( request.wc_form_encoding ).toBe( true );
 		} );

 		test( 'should send apply coupon data as an object', () => {
@@ -565,6 +568,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 					billing_email: BILLING_EMAIL,
 				} )
 			);
+			expect( request.wc_form_encoding ).toBe( true );
 		} );

 		test( 'should send remove coupon data as an object', () => {
@@ -582,6 +586,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			expect( request.data ).toEqual(
 				expect.objectContaining( { coupon: COUPON_CODE } )
 			);
+			expect( request.wc_form_encoding ).toBe( true );
 		} );
 	} );

diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce-prefilter-pipeline.js b/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce-prefilter-pipeline.js
new file mode 100644
index 00000000000..a28242f73db
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce-prefilter-pipeline.js
@@ -0,0 +1,225 @@
+/**
+ * @jest-environment jest-fixed-jsdom
+ */
+
+// Runs the woocommerce.js form encoding prefilter through the real jQuery
+// request pipeline on the version WordPress bundles (3.7.1) and on jQuery 4,
+// which runs prefilters before it serializes the data. The transport is a fake
+// XMLHttpRequest that records what reaches the wire, so each test pins the
+// body a browser would see.
+describe.each( [
+	[ 'jquery', '3.7.1' ],
+	[ 'jquery4', '4.0.0' ],
+] )(
+	'woocommerce.js form encoding prefilter with real jQuery (%s %s)',
+	( moduleName, expectedVersion ) => {
+		let $;
+		let sent;
+
+		beforeEach( () => {
+			sent = [];
+
+			class FakeXMLHttpRequest {
+				constructor() {
+					this.readyState = 0;
+					this.status = 0;
+					this.headers = {};
+				}
+				open( method, url ) {
+					this.method = method;
+					this.url = url;
+				}
+				setRequestHeader( name, value ) {
+					this.headers[ name ] = value;
+				}
+				getAllResponseHeaders() {
+					return '';
+				}
+				abort() {
+					this.aborted = true;
+				}
+				send( body ) {
+					sent.push( { method: this.method, url: this.url, body } );
+				}
+			}
+			// jQuery picks its transport when it loads, so the fake has to be in
+			// place before jQuery is required.
+			global.window.XMLHttpRequest = FakeXMLHttpRequest;
+			global.window.Cookies = { get: jest.fn(), set: jest.fn() };
+			global.window.woocommerce_params = {
+				i18n_password_show: 'Show',
+				i18n_password_hide: 'Hide',
+			};
+
+			jest.resetModules();
+			$ = require( moduleName );
+			expect( $.fn.jquery ).toBe( expectedVersion );
+			global.window.jQuery = $;
+			global.window.$ = $;
+			global.jQuery = $;
+			global.$ = $;
+			require( '../woocommerce' );
+		} );
+
+		afterEach( () => {
+			jest.clearAllMocks();
+		} );
+
+		const checkoutData = () => ( {
+			security: 'nonce',
+			country: 'US',
+			city: "O'Fallon",
+			address: "1 O'Fallon St (rear)",
+			company: 'Foo (Pty) Ltd!~',
+			// Fields removed from the form by a filter read as undefined.
+			state: undefined,
+			postcode: undefined,
+			has_full_address: true,
+			shipping_method: { 0: 'flat_rate:1' },
+		} );
+
+		test( 'should drop undefined fields, keep nested data and send what a browser form post would', () => {
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=update_order_review',
+				data: checkoutData(),
+				wc_form_encoding: true,
+			} );
+
+			const expected = new URLSearchParams( {
+				security: 'nonce',
+				country: 'US',
+				city: "O'Fallon",
+				address: "1 O'Fallon St (rear)",
+				company: 'Foo (Pty) Ltd!~',
+				has_full_address: 'true',
+				'shipping_method[0]': 'flat_rate:1',
+			} ).toString();
+
+			expect( sent ).toHaveLength( 1 );
+			expect( sent[ 0 ].body ).toBe( expected );
+			expect( sent[ 0 ].body ).not.toMatch( /[!'()~]/ );
+			expect( sent[ 0 ].body ).not.toContain( 'state=' );
+			expect( sent[ 0 ].body ).not.toContain( 'postcode=' );
+		} );
+
+		test( 'should leave requests without the opt-in as jQuery serializes them', () => {
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=update_order_review',
+				data: checkoutData(),
+			} );
+
+			expect( sent[ 0 ].body ).toBe(
+				"security=nonce&country=US&city=O'Fallon&address=1+O'Fallon+St+(rear)" +
+					'&company=Foo+(Pty)+Ltd!~&has_full_address=true&shipping_method%5B0%5D=flat_rate%3A1'
+			);
+		} );
+
+		test( 'should encode an already serialized form string', () => {
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=checkout',
+				data: "billing_email=jo.o'brien%40example.com&billing_phone=(212)+555-1234&reference=already%2527encoded",
+				wc_form_encoding: true,
+			} );
+
+			expect( sent[ 0 ].body ).toBe(
+				'billing_email=jo.o%27brien%40example.com&billing_phone=%28212%29+555-1234&reference=already%2527encoded'
+			);
+		} );
+
+		test( 'should let a prefilter registered later read the original data as an object', () => {
+			// The shape HurryTimer and Global Seller Services use, added after ours.
+			const seen = {};
+			$.ajaxPrefilter( ( options, originalOptions ) => {
+				seen.originalType = typeof originalOptions.data;
+				seen.couponCode = originalOptions.data.coupon_code;
+				seen.optionsData = options.data;
+			} );
+
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=apply_coupon',
+				data: { security: 'nonce', coupon_code: "SAVE'10" },
+				wc_form_encoding: true,
+			} );
+
+			expect( seen.originalType ).toBe( 'object' );
+			expect( seen.couponCode ).toBe( "SAVE'10" );
+			// Registered after ours, so it sees the encoded body in options.data
+			// on every jQuery version.
+			expect( seen.optionsData ).toBe( 'security=nonce&coupon_code=SAVE%2710' );
+			expect( sent[ 0 ].body ).toBe( 'security=nonce&coupon_code=SAVE%2710' );
+		} );
+
+		test( 'should keep a global beforeSend and an ajaxSend handler working', () => {
+			// The shapes FOX Currency Switcher (ajaxSend) and global ajaxSetup users rely on.
+			const beforeSend = jest.fn( ( jqXHR, settings ) => {
+				beforeSend.sawData = settings.data;
+			} );
+			$.ajaxSetup( { beforeSend } );
+			$( document ).ajaxSend( ( event, jqXHR, settings ) => {
+				settings.data += '&woocs_sk=key';
+			} );
+
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=apply_coupon',
+				data: { security: 'nonce', coupon_code: "SAVE'10" },
+				wc_form_encoding: true,
+			} );
+
+			expect( beforeSend ).toHaveBeenCalledTimes( 1 );
+			expect( beforeSend.sawData ).toBe( 'security=nonce&coupon_code=SAVE%2710' );
+			expect( sent[ 0 ].body ).toBe(
+				'security=nonce&coupon_code=SAVE%2710&woocs_sk=key'
+			);
+		} );
+
+		test( 'should honour the traditional option when it serializes array values', () => {
+			// Only jQuery 4 hands the prefilter unserialized data, so this pins
+			// the `options.traditional` pass-through on that path.
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=update_order_review',
+				data: { ids: [ 1, 2 ], city: "O'Fallon" },
+				traditional: true,
+				wc_form_encoding: true,
+			} );
+
+			expect( sent[ 0 ].body ).toBe( 'ids=1&ids=2&city=O%27Fallon' );
+		} );
+
+		test( 'should serialize an array of name/value pairs like serializeArray()', () => {
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=update_order_review',
+				data: [ { name: 'city', value: "O'Fallon" }, { name: 'state', value: 'NY' } ],
+				wc_form_encoding: true,
+			} );
+
+			expect( sent[ 0 ].body ).toBe( 'city=O%27Fallon&state=NY' );
+		} );
+
+		test( 'should leave a JSON body from an earlier json prefilter alone', () => {
+			// A dataType-specific prefilter runs before the '*' ones whatever the
+			// load order; a body it turned into JSON must not be rewritten.
+			$.ajaxPrefilter( 'json', ( options, originalOptions ) => {
+				options.contentType = 'application/json';
+				options.processData = false;
+				options.data = JSON.stringify( originalOptions.data );
+			} );
+
+			$.ajax( {
+				type: 'POST',
+				url: '/?wc-ajax=checkout',
+				dataType: 'json',
+				data: { billing_last_name: "O'Brien" },
+				wc_form_encoding: true,
+			} );
+
+			expect( sent[ 0 ].body ).toBe( '{"billing_last_name":"O\'Brien"}' );
+		} );
+	}
+);
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce.js b/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce.js
new file mode 100644
index 00000000000..4bd87c1a0f3
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce.js
@@ -0,0 +1,188 @@
+/**
+ * @jest-environment jest-fixed-jsdom
+ */
+
+// woocommerce.js registers a jQuery ajax prefilter that encodes the body of
+// requests that opt in with the wc_form_encoding option the way a browser form
+// submission would. jQuery serializes with encodeURIComponent(), which leaves
+// !'()~ literal; the WHATWG form serializer percent-encodes them, and some WAF
+// rules reject bodies with the literal characters (see #40791).
+describe( 'woocommerce.js form encoding prefilter', () => {
+	let prefilter;
+	const FORM = 'application/x-www-form-urlencoded; charset=UTF-8';
+
+	const loadScript = ( withAjaxPrefilter = true ) => {
+		const createDefaultMock = () => {
+			const mock = {
+				length: 0,
+				on: jest.fn( () => mock ),
+				each: jest.fn( () => mock ),
+				data: jest.fn(),
+				hide: jest.fn( () => mock ),
+				show: jest.fn( () => mock ),
+				wrap: jest.fn( () => mock ),
+				filter: jest.fn( () => mock ),
+				parent: jest.fn( () => mock ),
+				addClass: jest.fn( () => mock ),
+			};
+			return mock;
+		};
+		const jQueryMock = jest.fn( ( arg ) => {
+			// jQuery( fn ) is the DOM-ready wrapper; the prefilter is registered
+			// outside it, so the wrapper never has to run for these tests.
+			if ( typeof arg === 'function' ) {
+				return;
+			}
+			return createDefaultMock();
+		} );
+		if ( withAjaxPrefilter ) {
+			jQueryMock.ajaxPrefilter = jest.fn( ( handler ) => {
+				prefilter = handler;
+			} );
+		}
+		jQueryMock.isPlainObject = ( obj ) =>
+			!! obj && Object.getPrototypeOf( obj ) === Object.prototype;
+		jQueryMock.param = ( obj ) =>
+			Object.keys( obj )
+				.map(
+					( key ) =>
+						encodeURIComponent( key ) +
+						'=' +
+						encodeURIComponent( obj[ key ] )
+				)
+				.join( '&' );
+
+		global.window.jQuery = jQueryMock;
+		global.window.$ = jQueryMock;
+		global.jQuery = jQueryMock;
+		global.$ = jQueryMock;
+
+		jest.resetModules();
+		require( '../woocommerce' );
+		return jQueryMock;
+	};
+
+	afterEach( () => {
+		prefilter = undefined;
+		jest.clearAllMocks();
+	} );
+
+	test( 'should register one ajax prefilter when the script loads', () => {
+		const jQueryMock = loadScript();
+
+		expect( jQueryMock.ajaxPrefilter ).toHaveBeenCalledTimes( 1 );
+		expect( typeof prefilter ).toBe( 'function' );
+	} );
+
+	test( 'should load without throwing when jQuery has no ajax module', () => {
+		// A slim jQuery build has no ajaxPrefilter; the rest of woocommerce.js
+		// must still run.
+		expect( () => loadScript( false ) ).not.toThrow();
+		expect( prefilter ).toBeUndefined();
+	} );
+
+	test( 'should encode the characters a browser form post encodes', () => {
+		loadScript();
+		// What jQuery hands a prefilter: data already run through $.param(),
+		// where encodeURIComponent() keeps !'()~ literal and encodes `%` as %25.
+		const options = {
+			processData: true,
+			contentType: FORM,
+			data: "company=Foo+(Pty)+Ltd!~&city=O'Fallon&reference=already%2527encoded&keep=*-._",
+			wc_form_encoding: true,
+		};
+
+		prefilter( options );
+
+		expect( options.data ).toBe(
+			'company=Foo+%28Pty%29+Ltd%21%7E&city=O%27Fallon&reference=already%2527encoded&keep=*-._'
+		);
+		// Every value still decodes to what the shopper typed.
+		const body = new URLSearchParams( options.data );
+		expect( body.get( 'company' ) ).toBe( 'Foo (Pty) Ltd!~' );
+		expect( body.get( 'city' ) ).toBe( "O'Fallon" );
+		expect( body.get( 'reference' ) ).toBe( 'already%27encoded' );
+		expect( body.get( 'keep' ) ).toBe( '*-._' );
+	} );
+
+	test( 'should produce the same body URLSearchParams would', () => {
+		loadScript();
+		const fields = {
+			phone: '(212) 555-1234',
+			name: "O'Brien!~",
+			note: 'a*b-c._d',
+		};
+		const options = {
+			processData: true,
+			contentType: FORM,
+			// jQuery turns %20 into + after serializing.
+			data: global.jQuery.param( fields ).replace( /%20/g, '+' ),
+			wc_form_encoding: true,
+		};
+
+		prefilter( options );
+
+		expect( options.data ).toBe( new URLSearchParams( fields ).toString() );
+	} );
+
+	test( 'should serialize object data itself when jQuery has not yet done so', () => {
+		// jQuery 4 runs prefilters before jQuery.param(), so object data
+		// reaches the prefilter.
+		loadScript();
+		const options = {
+			processData: true,
+			contentType: FORM,
+			data: { city: "O'Fallon", state: 'NY' },
+			wc_form_encoding: true,
+		};
+
+		prefilter( options );
+
+		expect( options.data ).toBe( 'city=O%27Fallon&state=NY' );
+	} );
+
+	test( 'should leave requests without the opt-in alone', () => {
+		loadScript();
+		const options = {
+			processData: true,
+			contentType: FORM,
+			data: "city=O'Fallon",
+		};
+
+		prefilter( options );
+
+		expect( options.data ).toBe( "city=O'Fallon" );
+	} );
+
+	test( 'should leave a body that is not form-encoded alone even when opted in', () => {
+		// Only the content type differs from a WooCommerce request here, so
+		// this fails only when the content-type guard is removed.
+		loadScript();
+		const json = {
+			processData: true,
+			contentType: 'application/json',
+			data: '{"city":"O\'Fallon"}',
+			wc_form_encoding: true,
+		};
+
+		prefilter( json );
+
+		expect( json.data ).toBe( '{"city":"O\'Fallon"}' );
+	} );
+
+	test( 'should leave a body alone when processData is off even when opted in', () => {
+		// Only processData differs from a WooCommerce request here, so this
+		// fails only when the processData guard is removed.
+		loadScript();
+		const raw = {
+			processData: false,
+			contentType: FORM,
+			data: "city=O'Fallon",
+			wc_form_encoding: true,
+		};
+
+		prefilter( raw );
+
+		expect( raw.data ).toBe( "city=O'Fallon" );
+	} );
+} );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js b/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js
index 48d530c2f95..50f55eff677 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js
@@ -1,4 +1,49 @@
 /* global Cookies */
+
+/**
+ * Encode the body of requests that opt in with the `wc_form_encoding` option
+ * the way a browser form submission would.
+ *
+ * jQuery serializes with encodeURIComponent(), which leaves !'()~ literal,
+ * while a browser's form serializer percent-encodes them, and some WAF rules
+ * reject bodies with the literal characters. jQuery 1.x and 3.x serialize the
+ * data before prefilters run; jQuery 4 runs prefilters first, so plain object
+ * data is serialized here from the copy jQuery already made, which has removed
+ * (undefined) fields dropped. `originalOptions.data` stays the object the
+ * caller passed, and only form-encoded bodies are touched.
+ */
+if ( 'function' === typeof jQuery.ajaxPrefilter ) {
+	jQuery.ajaxPrefilter( function ( options ) {
+		if (
+			! options.wc_form_encoding ||
+			! options.processData ||
+			0 !==
+				( options.contentType || '' ).indexOf(
+					'application/x-www-form-urlencoded'
+				)
+		) {
+			return;
+		}
+		if (
+			jQuery.isPlainObject( options.data ) ||
+			Array.isArray( options.data )
+		) {
+			options.data = jQuery.param( options.data, options.traditional );
+		}
+		if ( 'string' === typeof options.data ) {
+			options.data = options.data.replace(
+				/[!'()~]/g,
+				function ( character ) {
+					return (
+						'%' +
+						character.charCodeAt( 0 ).toString( 16 ).toUpperCase()
+					);
+				}
+			);
+		}
+	} );
+}
+
 jQuery( function ( $ ) {
 	// Orderby
 	$( '.woocommerce-ordering' ).on( 'change', 'select.orderby', function () {
diff --git a/plugins/woocommerce/client/legacy/package.json b/plugins/woocommerce/client/legacy/package.json
index 679bad91ae2..bb2068b0d38 100644
--- a/plugins/woocommerce/client/legacy/package.json
+++ b/plugins/woocommerce/client/legacy/package.json
@@ -39,6 +39,8 @@
 		"grunt-sass": "3.1.0",
 		"grunt-stylelint": "0.20.1",
 		"jest-fixed-jsdom": "^0.0.10",
+		"jquery": "3.7.1",
+		"jquery4": "npm:jquery@4.0.0",
 		"sass": "^1.69.5",
 		"stylelint": "catalog:tooling"
 	},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 60198c5e871..886c2407408 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3885,6 +3885,12 @@ importers:
       jest-fixed-jsdom:
         specifier: ^0.0.10
         version: 0.0.10(jest-environment-jsdom@30.4.1)
+      jquery:
+        specifier: 3.7.1
+        version: 3.7.1
+      jquery4:
+        specifier: npm:jquery@4.0.0
+        version: jquery@4.0.0
       sass:
         specifier: 1.69.5
         version: 1.69.5
@@ -14897,6 +14903,12 @@ packages:
   jpeg-js@0.4.4:
     resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}

+  jquery@3.7.1:
+    resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==}
+
+  jquery@4.0.0:
+    resolution: {integrity: sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==}
+
   js-beautify@1.15.4:
     resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
     engines: {node: '>=14'}
@@ -36581,6 +36593,10 @@ snapshots:

   jpeg-js@0.4.4: {}

+  jquery@3.7.1: {}
+
+  jquery@4.0.0: {}
+
   js-beautify@1.15.4:
     dependencies:
       config-chain: 1.1.13