Commit 1fd0c64beba for php.net

commit 1fd0c64beba81ba552ad2969c24068d432d859c6
Author: Weilin Du <weilindu@php.net>
Date:   Tue Jul 28 00:03:52 2026 +0800

    ext/standard: Fix `setlocale()` NUL byte truncation (#22843)

    This reject NUL byte in the setlocale() function in the standard extension.

diff --git a/NEWS b/NEWS
index cc2eed87ccf..7ec4eb4b809 100644
--- a/NEWS
+++ b/NEWS
@@ -65,6 +65,12 @@ PHP                                                                        NEWS
     TCP_USER_TIMEOUT, and SO_LINGER options. (Weilin Du)
   . Fixed various memory related issues in ext/sockets. (David Carlier)

+- Standard:
+  . Fixed setlocale() to reject locale names containing NUL bytes instead of
+    silently truncating them, and to reject arrays passed after the $locales
+    argument or additional arguments passed after an array $locales argument.
+    (Weilin Du)
+
 - Streams:
   . Added a new IO copy API used by php_stream_copy_to_stream_ex() that
     leverages platform primitives (sendfile, splice, copy_file_range,
diff --git a/UPGRADING b/UPGRADING
index 34817c10974..d30a5663274 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -201,6 +201,12 @@ PHP 8.6 UPGRADE NOTES
     bytes.
   . parse_str() now raises a ValueError when the $string argument contains NUL
     bytes.
+  . setlocale() now raises a ValueError when a locale name contains NUL bytes,
+    instead of silently truncating it.
+    Arrays are now accepted only for the $locales argument. Passing an array as
+    a later variadic locale argument now throws a TypeError. Passing any
+    additional locale arguments when $locales is an array now throws an
+    ArgumentCountError.
   . linkinfo() now raises a ValueError when the $path argument is empty.
   . pathinfo() now raises a ValueError when an invalid $flag argument value is
     passed.
diff --git a/ext/standard/string.c b/ext/standard/string.c
index 823c32a8cf1..d8a7479d2a9 100644
--- a/ext/standard/string.c
+++ b/ext/standard/string.c
@@ -4909,6 +4909,11 @@ static zend_string *try_setlocale_zval(zend_long cat, zval *loc_zv) {
 	if (UNEXPECTED(loc_str == NULL)) {
 		return NULL;
 	}
+	if (zend_str_has_nul_byte(loc_str)) {
+		zend_argument_value_error(2, "must not contain any null bytes");
+		zend_tmp_string_release(tmp_loc_str);
+		return NULL;
+	}
 	zend_string *result = try_setlocale_str(cat, loc_str);
 	zend_tmp_string_release(tmp_loc_str);
 	return result;
@@ -4930,8 +4935,26 @@ PHP_FUNCTION(setlocale)
 	zend_string **strings = do_alloca(sizeof(zend_string *) * num_args, use_heap);

 	for (uint32_t i = 0; i < num_args; i++) {
-		if (UNEXPECTED(Z_TYPE(args[i]) != IS_ARRAY && !zend_parse_arg_str(&args[i], &strings[i], true, i + 2))) {
-			zend_wrong_parameter_type_error(i + 2, Z_EXPECTED_ARRAY_OR_STRING_OR_NULL, &args[i]);
+		if (Z_TYPE(args[i]) == IS_ARRAY) {
+			if (UNEXPECTED(i != 0)) {
+				zend_wrong_parameter_type_error(i + 2, Z_EXPECTED_STRING_OR_NULL, &args[i]);
+				goto out;
+			}
+			if (UNEXPECTED(num_args > 1)) {
+				zend_argument_count_error(
+					"setlocale() expects exactly 2 arguments when argument #2 ($locales) is an array, %d given",
+					ZEND_NUM_ARGS());
+				goto out;
+			}
+			break;
+		}
+		if (UNEXPECTED(!zend_parse_arg_path_str(&args[i], &strings[i], true, i + 2))) {
+			zend_wrong_parameter_type_error(
+				i + 2,
+				Z_TYPE(args[i]) == IS_STRING
+					? Z_EXPECTED_PATH
+					: (i == 0 ? Z_EXPECTED_ARRAY_OR_STRING_OR_NULL : Z_EXPECTED_STRING_OR_NULL),
+				&args[i]);
 			goto out;
 		}
 	}
diff --git a/ext/standard/tests/strings/gh18823_strict.phpt b/ext/standard/tests/strings/gh18823_strict.phpt
index 3735eab0067..39ae11ef61b 100644
--- a/ext/standard/tests/strings/gh18823_strict.phpt
+++ b/ext/standard/tests/strings/gh18823_strict.phpt
@@ -16,4 +16,4 @@
 ?>
 --EXPECT--
 setlocale(): Argument #2 ($locales) must be of type array|string|null, int given
-setlocale(): Argument #3 must be of type array|string|null, int given
+setlocale(): Argument #3 must be of type ?string, int given
diff --git a/ext/standard/tests/strings/setlocale_null_byte.phpt b/ext/standard/tests/strings/setlocale_null_byte.phpt
new file mode 100644
index 00000000000..85663b98a71
--- /dev/null
+++ b/ext/standard/tests/strings/setlocale_null_byte.phpt
@@ -0,0 +1,53 @@
+--TEST--
+setlocale() rejects locale names with null bytes
+--FILE--
+<?php
+class NullByteStringable {
+    public function __toString(): string {
+        return "C\0locale";
+    }
+}
+
+try {
+    var_dump(setlocale(LC_ALL, "C\0locale"));
+} catch (ValueError $e) {
+    echo $e::class, ": ", $e->getMessage(), \PHP_EOL;
+}
+
+try {
+    var_dump(setlocale(LC_ALL, ["locale\0name", "C"]));
+} catch (ValueError $e) {
+    echo $e::class, ": ", $e->getMessage(), \PHP_EOL;
+}
+
+try {
+    var_dump(@setlocale(LC_ALL, [str_repeat("x", 255), "C\0locale"]));
+} catch (ValueError $e) {
+    echo $e::class, ": ", $e->getMessage(), \PHP_EOL;
+}
+
+try {
+    var_dump(setlocale(LC_ALL, "zz_ZZ.nope", ["C\0locale"]));
+} catch (TypeError $e) {
+    echo $e::class, ": ", $e->getMessage(), \PHP_EOL;
+}
+
+try {
+    var_dump(setlocale(LC_ALL, "zz_ZZ.nope", new NullByteStringable()));
+} catch (ValueError $e) {
+    echo $e::class, ": ", $e->getMessage(), \PHP_EOL;
+}
+
+try {
+    var_dump(setlocale(LC_ALL, [], new NullByteStringable()));
+} catch (ArgumentCountError $e) {
+    echo $e::class, ": ", $e->getMessage(), \PHP_EOL;
+}
+?>
+--EXPECT--
+ValueError: setlocale(): Argument #2 ($locales) must not contain any null bytes
+ValueError: setlocale(): Argument #2 ($locales) must not contain any null bytes
+ValueError: setlocale(): Argument #2 ($locales) must not contain any null bytes
+TypeError: setlocale(): Argument #3 must be of type ?string, array given
+ValueError: setlocale(): Argument #3 must not contain any null bytes
+ArgumentCountError: setlocale() expects exactly 2 arguments when argument #2 ($locales) is an array, 3 given