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