Commit 7cda01098fd for woocommerce

commit 7cda01098fdd576ad6c2999ab69f392226c77ea3
Author: drwpcom <30090682+drwpcom@users.noreply.github.com>
Date:   Sat Sep 12 14:31:29 2026 -0300

    Fix: prevent numeric characters in thousand/decimal separator settings (#64248)

diff --git a/plugins/woocommerce/changelog/fix-wooplug-791-separator-numeric-validation b/plugins/woocommerce/changelog/fix-wooplug-791-separator-numeric-validation
new file mode 100644
index 00000000000..5b2dbc91a3f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-791-separator-numeric-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent numbers from being saved as the thousand or decimal separator in currency settings.
diff --git a/plugins/woocommerce/includes/wc-formatting-functions.php b/plugins/woocommerce/includes/wc-formatting-functions.php
index 94b2e8fa662..d3482879539 100644
--- a/plugins/woocommerce/includes/wc-formatting-functions.php
+++ b/plugins/woocommerce/includes/wc-formatting-functions.php
@@ -9,6 +9,7 @@
  */

 use Automattic\WooCommerce\Enums\WeightUnit;
+use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
 use Automattic\WooCommerce\Utilities\I18nUtil;
 use Automattic\WooCommerce\Utilities\NumberUtil;

@@ -1202,16 +1203,15 @@ function wc_format_product_short_description( $content ) {
 }

 /**
- * Formats currency symbols when saved in settings.
+ * Validates and sanitizes currency separators when saved in settings.
  *
- * @codeCoverageIgnore
- * @param  string $value     Option value.
- * @param  array  $option    Option name.
- * @param  string $raw_value Raw value.
- * @return string
+ * @param  mixed $value     Option value passed through earlier filters.
+ * @param  array $option    Option data including 'id' and 'default'.
+ * @param  mixed $raw_value Raw request value, null when the field was not submitted.
+ * @return mixed
  */
 function wc_format_option_price_separators( $value, $option, $raw_value ) {
-	return wp_kses_post( $raw_value ?? '' );
+	return wc_get_container()->get( OptionSanitizer::class )->sanitize_price_separator_setting( $value, $raw_value );
 }
 add_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_price_decimal_sep', 'wc_format_option_price_separators', 10, 3 );
 add_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_price_thousand_sep', 'wc_format_option_price_separators', 10, 3 );
diff --git a/plugins/woocommerce/src/Internal/Settings/OptionSanitizer.php b/plugins/woocommerce/src/Internal/Settings/OptionSanitizer.php
index 1f7af7230fc..5cbad4fd221 100644
--- a/plugins/woocommerce/src/Internal/Settings/OptionSanitizer.php
+++ b/plugins/woocommerce/src/Internal/Settings/OptionSanitizer.php
@@ -65,4 +65,34 @@ class OptionSanitizer {

 		return (string) $value;
 	}
+
+	/**
+	 * Rejects thousand and decimal separators that contain a number.
+	 * On rejection it adds a settings error and returns null, so the stored value is left untouched.
+	 *
+	 * @since 11.2.0
+	 * @param mixed $value     Option value.
+	 * @param mixed $raw_value Raw request value, null when the field was not submitted.
+	 * @return mixed
+	 *
+	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+	 */
+	public function sanitize_price_separator_setting( $value, $raw_value ) {
+		if ( null === $raw_value ) {
+			return $value;
+		}
+
+		if ( is_string( $raw_value ) ) {
+			$separator = wp_kses( $raw_value, array() );
+			$decoded   = html_entity_decode( $separator, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+
+			if ( 0 === preg_match( '/\p{N}/u', $decoded ) ) {
+				return $separator;
+			}
+		}
+
+		\WC_Admin_Settings::add_error( __( 'Thousand and decimal separators cannot contain numbers.', 'woocommerce' ) );
+
+		return null;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
index b5356a4452d..fbba65571b6 100644
--- a/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
@@ -1,4 +1,6 @@
 <?php
+declare( strict_types = 1 );
+
 /**
  * Formatting functions tests
  *
@@ -10,6 +12,17 @@
  */
 class WC_Formatting_Functions_Test extends \WC_Unit_Test_Case {

+	/**
+	 * Resets the static WC_Admin_Settings errors so they do not leak into other tests.
+	 */
+	public function tearDown(): void {
+		try {
+			$this->wc_admin_settings_errors_property()->setValue( null, array() );
+		} finally {
+			parent::tearDown();
+		}
+	}
+
 	/**
 	 * Data provider for test_wc_sanitize_coupon_code.
 	 *
@@ -88,6 +101,115 @@ class WC_Formatting_Functions_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( $assert, wc_format_postcode( $postcode, $country ), "Test formatting of $postcode postcodes." );
 	}

+	/**
+	 * Data provider for test_wc_format_option_price_separators.
+	 *
+	 * @return array[]
+	 */
+	public function data_provider_wc_format_option_price_separators(): array {
+		return array(
+			'thousand sep: comma'           => array( 'woocommerce_price_thousand_sep', ',', ',' ),
+			'thousand sep: period'          => array( 'woocommerce_price_thousand_sep', '.', '.' ),
+			'thousand sep: space'           => array( 'woocommerce_price_thousand_sep', ' ', ' ' ),
+			'thousand sep: two spaces'      => array( 'woocommerce_price_thousand_sep', '  ', '  ' ),
+			'thousand sep: empty'           => array( 'woocommerce_price_thousand_sep', '', '' ),
+			'thousand sep: nbsp entity'     => array( 'woocommerce_price_thousand_sep', '&nbsp;', '&nbsp;' ),
+			'thousand sep: comma entity'    => array( 'woocommerce_price_thousand_sep', '&#44;', '&#044;' ),
+			'thousand sep: zero digit'      => array( 'woocommerce_price_thousand_sep', '0', null ),
+			'thousand sep: single digit'    => array( 'woocommerce_price_thousand_sep', '1', null ),
+			'thousand sep: digit+symbol'    => array( 'woocommerce_price_thousand_sep', '1,', null ),
+			'thousand sep: digit entity'    => array( 'woocommerce_price_thousand_sep', '&#49;', null ),
+			'thousand sep: fullwidth digit' => array( 'woocommerce_price_thousand_sep', '1', null ),
+			'decimal sep: period'           => array( 'woocommerce_price_decimal_sep', '.', '.' ),
+			'decimal sep: single digit'     => array( 'woocommerce_price_decimal_sep', '2', null ),
+			'decimal sep: arabic digit'     => array( 'woocommerce_price_decimal_sep', '٢', null ),
+		);
+	}
+
+	/**
+	 * @testdox wc_format_option_price_separators should reject values containing digits by adding an error and returning null.
+	 *
+	 * @dataProvider data_provider_wc_format_option_price_separators
+	 *
+	 * @param string      $option_id The option being saved.
+	 * @param string      $raw_value The raw input being saved.
+	 * @param string|null $expected  The value the filter should return, null when the input is rejected.
+	 */
+	public function test_wc_format_option_price_separators( string $option_id, string $raw_value, ?string $expected ): void {
+		$option = array(
+			'id'      => $option_id,
+			'default' => ',',
+		);
+
+		$errors_before = $this->get_wc_admin_settings_errors();
+		$result        = wc_format_option_price_separators( $raw_value, $option, $raw_value );
+		$errors_after  = $this->get_wc_admin_settings_errors();
+
+		$this->assertSame( $expected, $result );
+
+		if ( null === $expected ) {
+			$this->assertCount( count( $errors_before ) + 1, $errors_after, 'An error should be added when a numeric separator is rejected.' );
+			$this->assertStringContainsString( 'cannot contain numbers', end( $errors_after ), 'Error message should mention numbers.' );
+		} else {
+			$this->assertCount( count( $errors_before ), $errors_after, 'No error should be added for valid separators.' );
+		}
+	}
+
+	/**
+	 * @testdox wc_format_option_price_separators should reject a non-string raw value.
+	 */
+	public function test_wc_format_option_price_separators_rejects_non_string_input(): void {
+		$option = array(
+			'id'      => 'woocommerce_price_thousand_sep',
+			'default' => ',',
+		);
+
+		$errors_before = $this->get_wc_admin_settings_errors();
+		$result        = wc_format_option_price_separators( ',', $option, array( '1' ) );
+		$errors_after  = $this->get_wc_admin_settings_errors();
+
+		$this->assertNull( $result, 'An array raw value should be rejected.' );
+		$this->assertCount( count( $errors_before ) + 1, $errors_after, 'An error should be added when the raw value is not a string.' );
+	}
+
+	/**
+	 * @testdox wc_format_option_price_separators should leave the value alone when the field was not submitted.
+	 */
+	public function test_wc_format_option_price_separators_skips_missing_field(): void {
+		$option = array(
+			'id'      => 'woocommerce_price_thousand_sep',
+			'default' => ',',
+		);
+
+		$errors_before = $this->get_wc_admin_settings_errors();
+		$result        = wc_format_option_price_separators( null, $option, null );
+		$errors_after  = $this->get_wc_admin_settings_errors();
+
+		$this->assertNull( $result, 'A null raw value means the field was not submitted, so nothing should be saved.' );
+		$this->assertCount( count( $errors_before ), $errors_after, 'No error should be added for a field that was not submitted.' );
+	}
+
+	/**
+	 * Reads the private static $errors array from WC_Admin_Settings via reflection.
+	 *
+	 * @return array
+	 */
+	private function get_wc_admin_settings_errors(): array {
+		return $this->wc_admin_settings_errors_property()->getValue();
+	}
+
+	/**
+	 * Returns the private static $errors property of WC_Admin_Settings.
+	 *
+	 * @return ReflectionProperty
+	 */
+	private function wc_admin_settings_errors_property(): ReflectionProperty {
+		$reflection = new \ReflectionClass( WC_Admin_Settings::class );
+		$property   = $reflection->getProperty( 'errors' );
+		$property->setAccessible( true );
+		return $property;
+	}
+
 	/**
 	 * Test wc_is_stock_amount_integer function.
 	 *