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', ' ', ' ' ),
+ 'thousand sep: comma entity' => array( 'woocommerce_price_thousand_sep', ',', ',' ),
+ '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', '1', 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.
*