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