Commit 11f0cf6474d for woocommerce
commit 11f0cf6474db90d0055c06c3755893759e118d88
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Mon Oct 12 11:32:32 2026 +0300
Revert apostrophe encoding in classic cart requests (#69696)
* Revert apostrophe encoding in classic cart requests
This reverts commit 21a50fa4345a924e31ce4ffb25b1a1bb469a3907 (#68556).
#68556 made the classic cart send apply_coupon and remove_coupon data
as a pre-serialized string (encodeApostrophes( $.param( data ) ))
instead of a data object. That is the same shape #68365 introduced on
the classic checkout, which 11.2.0 shipped and #69644 reverted in
11.2.1: third-party jQuery.ajaxPrefilter callbacks that read
originalOptions.data as an object get a string. HurryTimer's coupon
expiry check is one example; with a string it no longer blocks an
expired coupon.
#68556 is on trunk only, so without this revert it ships in 11.3.0.
Reverting restores the 11.2 cart request shape for all five request
sites, removes the encodeApostrophes() helper and its tests, and
replaces the unreleased fix changelog entry with a dev entry.
The apostrophe encoding itself is tracked separately in #69679 and
will only land if it is shown to be safe.
Refs #68395
* Drop the dev changelog entry from the cart revert
The revert already deletes the unreleased fix entry that #68556 added,
which is the only changelog file the change ever produced. Nothing
shipped, so there is nothing for the release notes to say, and the
changelog check passes because the PR touches the changes directory.
The branch now matches a plain git revert of #68556.
Refs #68395
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-68395-cart-apostrophe-encoding b/plugins/woocommerce/changelog/fix-68395-cart-apostrophe-encoding
deleted file mode 100644
index f3afe0765c3..00000000000
--- a/plugins/woocommerce/changelog/fix-68395-cart-apostrophe-encoding
+++ /dev/null
@@ -1,4 +0,0 @@
-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 b39fb605b9c..f704eeaa5c3 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/cart.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/cart.js
@@ -19,19 +19,6 @@ 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.
*
@@ -387,7 +374,7 @@ jQuery( function ( $ ) {
$.ajax( {
type: $form.attr( 'method' ),
url: $form.attr( 'action' ),
- data: encodeApostrophes( $form.serialize() ),
+ data: $form.serialize(),
dataType: 'html',
success: function ( response ) {
update_wc_div( response );
@@ -504,7 +491,7 @@ jQuery( function ( $ ) {
$.ajax( {
type: $form.attr( 'method' ),
url: $form.attr( 'action' ),
- data: encodeApostrophes( $form.serialize() ),
+ data: $form.serialize(),
dataType: 'html',
success: function ( response ) {
update_wc_div( response, preserve_notices );
@@ -637,7 +624,7 @@ jQuery( function ( $ ) {
$.ajax( {
type: 'POST',
url: get_url( 'apply_coupon' ),
- data: encodeApostrophes( $.param( data ) ),
+ data: data,
dataType: 'html',
success: function ( response ) {
$(
@@ -695,7 +682,7 @@ jQuery( function ( $ ) {
$.ajax( {
type: 'POST',
url: get_url( 'remove_coupon' ),
- data: encodeApostrophes( $.param( data ) ),
+ data: data,
dataType: 'html',
success: function ( response ) {
$(
@@ -754,7 +741,7 @@ jQuery( function ( $ ) {
$.ajax( {
type: $form.attr( 'method' ),
url: $form.attr( 'action' ),
- data: encodeApostrophes( $form.serialize() ),
+ data: $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
deleted file mode 100644
index ffc263cf3e7..00000000000
--- a/plugins/woocommerce/client/legacy/js/frontend/test/cart.js
+++ /dev/null
@@ -1,358 +0,0 @@
-/**
- * @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
- );
- } );
-} );