Commit ee862f4e064 for woocommerce
commit ee862f4e064e10914a744c4c7a6bafaed63ed321
Author: Francesco <frosso@users.noreply.github.com>
Date: Wed Sep 9 18:46:26 2026 +0200
fix: strip invisible characters from phone numbers (#68164)
diff --git a/plugins/woocommerce/changelog/fix-58000-strip-invisible-chars-phone b/plugins/woocommerce/changelog/fix-58000-strip-invisible-chars-phone
new file mode 100644
index 00000000000..d1ea3fcf01f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-58000-strip-invisible-chars-phone
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Strip invisible Unicode characters from phone numbers so pasted numbers are not rejected over characters the customer cannot see, store the cleaned number from the Checkout block and the My Account address form, and stop the classic checkout marking the field invalid. wc_remove_non_displayable_chars() now removes every Unicode default-ignorable code point, including the word joiner (U+2060) and the variation selectors it used to keep.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/checkout.js b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
index 8b59f069c3d..81911b423e4 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/checkout.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
@@ -20,6 +20,22 @@ jQuery( function ( $ ) {
$.blockUI.defaults.overlayCSS.cursor = 'default';
+ // A paste can carry characters that render as nothing. The server strips them
+ // before validating, so flagging the field here would blame the customer for
+ // something invisible. Mirrors the strip in wc_remove_non_displayable_chars(),
+ // not the server's phone check — that one is stricter, so a number can pass
+ // here and still be refused on submit. No "u" flag, and surrogate pairs for the
+ // ranges above U+FFFF, so the pattern builds on any browser.
+ var invisible_chars = new RegExp(
+ '[\\u00AD\\u034F\\u061C\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F' +
+ '\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u206F\\u3164' +
+ '\\uFE00-\\uFE0F\\uFEFF\\uFFA0\\uFFF0-\\uFFFB]' +
+ '|\\uD82F[\\uDCA0-\\uDCA3]' +
+ '|\\uD834[\\uDD73-\\uDD7A]' +
+ '|[\\uDB40-\\uDB43][\\uDC00-\\uDFFF]',
+ 'g'
+ );
+
/**
* Create the API object passed to custom place order button render callbacks.
* This is checkout-specific and includes form validation.
@@ -595,7 +611,13 @@ jQuery( function ( $ ) {
if ( validate_phone ) {
pattern = new RegExp( /[\s\#0-9_\-\+\/\(\)\.]/g );
- if ( 0 < $this.val().replace( pattern, '' ).length ) {
+ if (
+ 0 <
+ $this
+ .val()
+ .replace( invisible_chars, '' )
+ .replace( pattern, '' ).length
+ ) {
$this.attr( 'aria-invalid', 'true' );
$parent
.removeClass( 'woocommerce-validated' )
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index 21cc00b382d..e1a90b6a45d 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -280,7 +280,11 @@ class WC_Form_Handler {
case 'phone':
$country = wc_clean( wp_unslash( $_POST[ $address_type . '_country' ] ) );
$country = is_string( $country ) ? $country : '';
- if ( '' !== $value && ! WC_Validation::is_phone( $value, $country ) ) {
+ // Strip before validating, so a customer is not rejected over
+ // characters they cannot see. A value that is nothing but those
+ // characters is not a phone number.
+ $value = wc_remove_non_displayable_chars( (string) $value );
+ if ( '' === $value || ! WC_Validation::is_phone( $value, $country ) ) {
/* translators: %s: Phone number. */
wc_add_notice( sprintf( __( '%s is not a valid phone number.', 'woocommerce' ), '<strong>' . $field['label'] . '</strong>' ), 'error' );
}
diff --git a/plugins/woocommerce/includes/wc-formatting-functions.php b/plugins/woocommerce/includes/wc-formatting-functions.php
index 4d379a00aac..94b2e8fa662 100644
--- a/plugins/woocommerce/includes/wc-formatting-functions.php
+++ b/plugins/woocommerce/includes/wc-formatting-functions.php
@@ -1648,45 +1648,64 @@ function wc_sanitize_endpoint_slug( $raw_value ) {
/**
* Removes useless non-displayable and problematic Unicode characters from a string.
*
- * This function eliminates characters that can cause formatting issues, invisible text,
- * or unexpected behavior in copy-pasted text. Specifically, it removes:
+ * Covers the characters that are invisible to the reader but still count as content to
+ * anything matching on the string: soft hyphen, zero-width spaces and joiners, bidirectional
+ * marks, isolates and overrides, variation selectors, Hangul and Mongolian fillers, tag
+ * characters, and the byte order mark. Interlinear annotation marks (`U+FFF9`–`U+FFFB`) are
+ * removed too, though they are not invisible.
*
- * - **Soft hyphen (`U+00AD`)** – Invisible unless text is broken across lines.
- * - **Zero-width spaces & joiners (`U+200B–U+200D`)** – Invisible and can cause copy/paste issues.
- * - **Directional markers (`U+200E–U+200F`, `U+202A–U+202E`)** – Can affect text rendering.
- * - **Byte Order Mark (BOM) (`U+FEFF`)** – Can interfere with encoding.
- * - **Interlinear annotation characters (`U+FFF9–U+FFFB`)** – Rarely used and unnecessary in checkout fields.
+ * Copy-pasting from PDFs, word processors and web pages routinely carries these along, and a
+ * reader has no way to see why the value they pasted is being rejected.
*
- * It does **not** remove:
- *
- * - **Non-breaking space (`U+00A0`)** – Useful for preventing line breaks in addresses.
- * - **Word joiner (`U+2060`)** – Sometimes needed for proper text rendering in certain scripts.
+ * Non-breaking spaces (`U+00A0`, `U+202F`) are **not** removed. They are visible spaces, so
+ * deleting them would run words together rather than restore the intended text.
*
* @param string $raw_value The input string to sanitize.
*
* @return string The sanitized string without problematic characters.
* @since 9.9.0
+ * @since 11.2.0 Removes every Unicode default-ignorable code point, including the word joiner
+ * (`U+2060`) and the variation selectors earlier versions kept.
*/
function wc_remove_non_displayable_chars( string $raw_value ): string {
- $remove_chars = array(
- "\u{00AD}", // Soft Hyphen.
- "\u{200B}", // Zero Width Space.
- "\u{200C}", // Zero Width Non-Joiner.
- "\u{200D}", // Zero Width Joiner.
- "\u{200E}", // Left-to-Right Mark.
- "\u{200F}", // Right-to-Left Mark.
- "\u{202A}", // Left-to-Right Embedding.
- "\u{202B}", // Right-to-Left Embedding.
- "\u{202C}", // Pop Directional Formatting.
- "\u{202D}", // Left-to-Right Override.
- "\u{202E}", // Right-to-Left Override.
- "\u{FEFF}", // Byte Order Mark (BOM).
- "\u{FFF9}", // Interlinear Annotation Anchor.
- "\u{FFFA}", // Interlinear Annotation Separator.
- "\u{FFFB}", // Interlinear Annotation Terminator.
+ // Ranges rather than \p{Default_Ignorable_Code_Point}: that property needs PCRE2 10.43,
+ // newer than the build many supported installs link against. An unsupported property
+ // makes preg_replace return null instead of failing loudly.
+ $pattern = '/['
+ . '\x{00AD}\x{034F}\x{061C}\x{115F}\x{1160}\x{17B4}\x{17B5}\x{180B}-\x{180F}'
+ . '\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{206F}\x{3164}'
+ . '\x{FE00}-\x{FE0F}\x{FEFF}\x{FFA0}\x{FFF0}-\x{FFFB}'
+ . '\x{1BCA0}-\x{1BCA3}\x{1D173}-\x{1D17A}\x{E0000}-\x{E0FFF}'
+ . ']/u';
+
+ $result = preg_replace( $pattern, '', $raw_value );
+
+ if ( null !== $result ) {
+ return $result;
+ }
+
+ // Malformed UTF-8 makes a /u pattern bail. Fall back to a byte-wise replacement over the
+ // soft hyphen, zero-width characters, bidirectional marks, BOM and interlinear annotation
+ // marks, so such input is cleaned no less than it was before this pattern existed.
+ $fallback_chars = array(
+ "\u{00AD}",
+ "\u{200B}",
+ "\u{200C}",
+ "\u{200D}",
+ "\u{200E}",
+ "\u{200F}",
+ "\u{202A}",
+ "\u{202B}",
+ "\u{202C}",
+ "\u{202D}",
+ "\u{202E}",
+ "\u{FEFF}",
+ "\u{FFF9}",
+ "\u{FFFA}",
+ "\u{FFFB}",
);
- return str_replace( $remove_chars, '', $raw_value );
+ return str_replace( $fallback_chars, '', $raw_value );
}
add_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_checkout_pay_endpoint', 'wc_sanitize_endpoint_slug', 10, 1 );
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index fcb769fb518..617f2647e9a 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -148,6 +148,12 @@ abstract class AbstractAddressSchema extends AbstractSchema {
[]
);
+ // After the loop, so this cleans the value the schema sanitizer produced rather than
+ // the raw one from the request.
+ if ( isset( $address['phone'] ) && is_string( $address['phone'] ) ) {
+ $address['phone'] = wc_remove_non_displayable_chars( $address['phone'] );
+ }
+
return $sanitization_util->wp_kses_array( $address );
}
@@ -200,6 +206,8 @@ abstract class AbstractAddressSchema extends AbstractSchema {
return $errors;
}
+ // Validation runs before sanitization in the REST dispatcher, so sanitize here to check
+ // the same value that will be stored. The phone checks below rely on it.
$address = $this->sanitize_callback( $address, $request, $param );
if ( ! empty( $address['country'] ) && ! in_array( $address['country'], array_keys( wc()->countries->get_countries() ), true ) ) {
@@ -233,16 +241,11 @@ abstract class AbstractAddressSchema extends AbstractSchema {
);
}
- if ( ! empty( $address['phone'] ) ) {
- // This is a safe sanitize to prevent copy-paste issues with invisible chars. Won't ensure validation.
- $address['phone'] = wc_remove_non_displayable_chars( $address['phone'] );
-
- if ( ! \WC_Validation::is_phone( $address['phone'], $address['country'] ?? null ) ) {
- $errors->add(
- 'invalid_phone',
- __( 'The provided phone number is not valid', 'woocommerce' )
- );
- }
+ if ( ! empty( $address['phone'] ) && ! \WC_Validation::is_phone( $address['phone'], $address['country'] ?? null ) ) {
+ $errors->add(
+ 'invalid_phone',
+ __( 'The provided phone number is not valid', 'woocommerce' )
+ );
}
$additional_fields = array_intersect_key(
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php
index f8365d62edd..a539feabd3b 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php
@@ -1207,7 +1207,82 @@ class WC_Tests_Formatting_Functions extends WC_Unit_Test_Case {
// String with non-breaking space (U+00A0), should be preserved.
$this->assertEquals( "Hello\xC2\xA0World", wc_remove_non_displayable_chars( "Hello\xC2\xA0World" ) );
- // String with word joiner (U+2060), should be preserved.
- $this->assertEquals( "Join\xE2\x81\xA0Me", wc_remove_non_displayable_chars( "Join\xE2\x81\xA0Me" ) );
+ // String with narrow non-breaking space (U+202F), should be preserved.
+ $this->assertEquals( "Hello\xE2\x80\xAFWorld", wc_remove_non_displayable_chars( "Hello\xE2\x80\xAFWorld" ) );
+
+ // String with word joiner (U+2060), should be removed.
+ $this->assertEquals( 'JoinMe', wc_remove_non_displayable_chars( "Join\xE2\x81\xA0Me" ) );
+ }
+
+ /**
+ * Invisible characters that a copy-paste can carry into a form field.
+ *
+ * @return array[]
+ */
+ public function data_provider_invisible_chars() {
+ return array(
+ 'combining grapheme joiner (U+034F)' => array( "\u{034F}" ),
+ 'arabic letter mark (U+061C)' => array( "\u{061C}" ),
+ 'hangul choseong filler (U+115F)' => array( "\u{115F}" ),
+ 'khmer vowel inherent aq (U+17B4)' => array( "\u{17B4}" ),
+ 'mongolian free variation (U+180B)' => array( "\u{180B}" ),
+ 'left-to-right isolate (U+2066)' => array( "\u{2066}" ),
+ 'pop directional isolate (U+2069)' => array( "\u{2069}" ),
+ 'hangul filler (U+3164)' => array( "\u{3164}" ),
+ 'variation selector-1 (U+FE00)' => array( "\u{FE00}" ),
+ 'variation selector-14 (U+FE0D)' => array( "\u{FE0D}" ),
+ 'variation selector-16 (U+FE0F)' => array( "\u{FE0F}" ),
+ 'halfwidth hangul filler (U+FFA0)' => array( "\u{FFA0}" ),
+ 'reserved format char (U+FFF0)' => array( "\u{FFF0}" ),
+ 'variation selector-17 (U+E0100)' => array( "\u{E0100}" ),
+ 'variation selector-256 (U+E01EF)' => array( "\u{E01EF}" ),
+ 'shorthand format control (U+1BCA0)' => array( "\u{1BCA0}" ),
+ 'musical format control (U+1D173)' => array( "\u{1D173}" ),
+ 'tag latin small letter a (U+E0061)' => array( "\u{E0061}" ),
+ );
+ }
+
+ /**
+ * Test wc_remove_non_displayable_chars() strips characters the reader cannot see.
+ *
+ * @since 11.2.0
+ *
+ * @dataProvider data_provider_invisible_chars
+ *
+ * @param string $char The invisible character to strip.
+ */
+ public function test_wc_remove_non_displayable_chars_strips_invisible_chars( $char ) {
+ $this->assertEquals( '+15551234567', wc_remove_non_displayable_chars( '+1' . $char . '5551234567' ) );
+ }
+
+ /**
+ * Test wc_remove_non_displayable_chars() clears the phone number reported in issue #58000.
+ *
+ * The variation selector between "1" and "-" is invisible, so the customer had no way to
+ * see why the number they pasted was rejected.
+ *
+ * @since 11.2.0
+ */
+ public function test_wc_remove_non_displayable_chars_allows_pasted_phone_number_to_validate() {
+ $pasted = "+1\u{FE0D}-555-123-4567";
+
+ $this->assertFalse( WC_Validation::is_phone( $pasted ) );
+ $this->assertEquals( '+1-555-123-4567', wc_remove_non_displayable_chars( $pasted ) );
+ $this->assertTrue( WC_Validation::is_phone( wc_remove_non_displayable_chars( $pasted ) ) );
+ }
+
+ /**
+ * Test wc_remove_non_displayable_chars() still strips on malformed UTF-8.
+ *
+ * A /u pattern returns null rather than a string on invalid UTF-8, which would be fatal
+ * against this function's return type. The fallback has to clean the input no less than
+ * the byte-wise replacement this function used before.
+ *
+ * @since 11.2.0
+ */
+ public function test_wc_remove_non_displayable_chars_handles_malformed_utf8() {
+ $malformed = "12\xC3\x2834\xC2\xAD56";
+
+ $this->assertEquals( "12\xC3\x28" . '3456', wc_remove_non_displayable_chars( $malformed ) );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
index de21b78aa4f..84ba8e8fe9b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
@@ -113,6 +113,50 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
$this->assertSame( 'Suite 100', $result['address_2'], 'A plain field should be unchanged.' );
}
+ /**
+ * @testdox Should strip invisible characters from the phone number it returns.
+ *
+ * Sanitizing rather than validating is what makes the cleaned number the one that gets
+ * stored. Validation used to strip a copy and discard it, so the number saved against the
+ * order kept characters the customer could not see.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/58000
+ */
+ public function test_strips_invisible_characters_from_phone(): void {
+ $address = $this->make_address( array( 'phone' => "+1\u{FE0D}-555-123-4567" ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( '+1-555-123-4567', $result['phone'] );
+ }
+
+ /**
+ * @testdox Should leave a phone number without invisible characters alone.
+ */
+ public function test_does_not_alter_a_clean_phone(): void {
+ $address = $this->make_address( array( 'phone' => '+1 (800) 123-4567' ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( '+1 (800) 123-4567', $result['phone'] );
+ }
+
+ /**
+ * @testdox Should return a string phone whatever type the request sent.
+ *
+ * The schema sanitizer coerces the value before the strip runs, so a null or numeric
+ * phone has to reach wc_remove_non_displayable_chars() as a string.
+ */
+ public function test_handles_non_string_phone(): void {
+ foreach ( array( null, 42 ) as $phone ) {
+ $address = $this->make_address( array( 'phone' => $phone ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertIsString( $result['phone'] );
+ }
+ }
+
/**
* @testdox Should not texturize billing email addresses in API responses.
*/