Commit 41f2ca4dd4e for php.net

commit 41f2ca4dd4eef3ed005adb7b1f074f0cc0a4f9ef
Author: Weilin Du <weilindu@php.net>
Date:   Fri Jul 31 00:18:12 2026 +0800

    ext/soap: Fix SOAP classmap validation for integer keys (#22884)

    Previously, SOAP only rejected packed arrays, which still allowed sparse
    integer-keyed arrays and mixed string integer-keyed arrays to be accepted.

    Since classmap is expected to be an associative mapping, arrays containing
    integer keys are now rejected consistently.

    Co-authored-by: David CARLIER <devnexen@gmail.com>
    Co-authored-by: NickSdot <32384907+NickSdot@users.noreply.github.com>

diff --git a/NEWS b/NEWS
index d90af7f5e64..ca60824033c 100644
--- a/NEWS
+++ b/NEWS
@@ -79,6 +79,9 @@ PHP                                                                        NEWS
 - SOAP:
   . Fixed header injection through the Content-Type context option, the
     soapaction and the cookie names and values. (David Carlier)
+  . Fixed the SoapClient and SoapServer "classmap" option to reject arrays
+    containing integer keys, and made SoapClient throw TypeError/ValueError
+    for invalid "classmap" options. (Weilin Du, David Carlier)

 - MBString:
   . Fixed bug GH-22779 (mb_strrpos() returns the wrong position for a negative
diff --git a/UPGRADING b/UPGRADING
index b516f24db97..8f107d19ec2 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -160,6 +160,11 @@ PHP 8.6 UPGRADE NOTES
     system.

 - SOAP:
+  . The "classmap" option of SoapClient and SoapServer now rejects arrays
+    containing integer keys. Previously, sparse integer-keyed and mixed-keyed
+    arrays could be accepted. SoapClient now throws TypeError for non-array
+    "classmap" options and ValueError for arrays containing integer keys, also
+    when the "exceptions" option is disabled.
   . WSDL/XML Schema parsing now rejects out-of-range integer values for
     occurrence constraints and integer restriction facets. Negative minOccurs
     and maxOccurs values are rejected as well.
diff --git a/ext/soap/php_encoding.c b/ext/soap/php_encoding.c
index d08c5a68318..72de145e7b0 100644
--- a/ext/soap/php_encoding.c
+++ b/ext/soap/php_encoding.c
@@ -440,7 +440,7 @@ static xmlNodePtr master_to_xml_int(encodePtr encode, zval *data, int style, xml
 			zval *tmp;
 			zend_string *type_name;

-			ZEND_HASH_MAP_FOREACH_STR_KEY_VAL(SOAP_GLOBAL(class_map), type_name, tmp) {
+			ZEND_HASH_FOREACH_STR_KEY_VAL(SOAP_GLOBAL(class_map), type_name, tmp) {
 				ZVAL_DEREF(tmp);
 				if (Z_TYPE_P(tmp) == IS_STRING &&
 				    ZSTR_LEN(ce->name) == Z_STRLEN_P(tmp) &&
diff --git a/ext/soap/soap.c b/ext/soap/soap.c
index 171b529b335..4fade198d02 100644
--- a/ext/soap/soap.c
+++ b/ext/soap/soap.c
@@ -930,6 +930,18 @@ static HashTable* soap_create_typemap(sdlPtr sdl, HashTable *ht) /* {{{ */
 }
 /* }}} */

+static bool soap_class_map_has_only_string_keys(const HashTable *class_map)
+{
+	zend_string *key;
+	ZEND_HASH_FOREACH_STR_KEY(class_map, key) {
+		if (UNEXPECTED(key == NULL)) {
+			return false;
+		}
+	} ZEND_HASH_FOREACH_END();
+
+	return true;
+}
+
 /* {{{ SoapServer constructor */
 PHP_METHOD(SoapServer, __construct)
 {
@@ -1007,8 +1019,7 @@ PHP_METHOD(SoapServer, __construct)
 				zend_argument_type_error(2, "\"classmap\" option must be of type array, %s given", zend_zval_type_name(class_map_zv));
 				goto cleanup;
 			}
-			// TODO: this still accepts mixed keys arrays and not all numerically indexed arrays are packed
-			if (UNEXPECTED(HT_IS_PACKED(Z_ARRVAL_P(class_map_zv)))) {
+			if (UNEXPECTED(!soap_class_map_has_only_string_keys(Z_ARRVAL_P(class_map_zv)))) {
 				zend_argument_value_error(2, "\"classmap\" option must be an associative array");
 				goto cleanup;
 			}
@@ -2104,6 +2115,20 @@ PHP_METHOD(SoapClient, __construct)
 		RETURN_THROWS();
 	}

+	if (options != NULL) {
+		zval *classmap = zend_hash_str_find(Z_ARRVAL_P(options), "classmap", sizeof("classmap")-1);
+		if (classmap != NULL) {
+			if (UNEXPECTED(Z_TYPE_P(classmap) != IS_ARRAY)) {
+				zend_argument_type_error(2, "\"classmap\" option must be of type array, %s given", zend_zval_type_name(classmap));
+				RETURN_THROWS();
+			}
+			if (UNEXPECTED(!soap_class_map_has_only_string_keys(Z_ARRVAL_P(classmap)))) {
+				zend_argument_value_error(2, "\"classmap\" option must be an associative array");
+				RETURN_THROWS();
+			}
+		}
+	}
+
 	SOAP_CLIENT_BEGIN_CODE();

 	cache_wsdl = SOAP_GLOBAL(cache_enabled) ? SOAP_GLOBAL(cache_mode) : 0;
@@ -2134,14 +2159,6 @@ PHP_METHOD(SoapClient, __construct)
 			}
 		}

-		if ((tmp = zend_hash_str_find(ht, "stream_context", sizeof("stream_context")-1)) != NULL &&
-				Z_TYPE_P(tmp) == IS_RESOURCE) {
-			context = php_stream_context_from_zval(tmp, 1);
-			Z_ADDREF_P(tmp);
-		} else {
-			context = php_stream_context_alloc();
-		}
-
 		if ((tmp = zend_hash_str_find(ht, "location", sizeof("location")-1)) != NULL &&
 		    Z_TYPE_P(tmp) == IS_STRING) {
 			ZVAL_STR_COPY(Z_CLIENT_LOCATION_P(this_ptr), Z_STR_P(tmp));
@@ -2187,17 +2204,6 @@ PHP_METHOD(SoapClient, __construct)
 				}
 			}
 		}
-		if ((tmp = zend_hash_str_find(ht, "local_cert", sizeof("local_cert")-1)) != NULL &&
-		    Z_TYPE_P(tmp) == IS_STRING) {
-			if (!context) {
-				context = php_stream_context_alloc();
-			}
-			php_stream_context_set_option(context, "ssl", "local_cert", tmp);
-			if ((tmp = zend_hash_str_find(ht, "passphrase", sizeof("passphrase")-1)) != NULL &&
-			    Z_TYPE_P(tmp) == IS_STRING) {
-				php_stream_context_set_option(context, "ssl", "passphrase", tmp);
-			}
-		}
 		if ((tmp = zend_hash_find(ht, ZSTR_KNOWN(ZEND_STR_TRACE))) != NULL &&
 		    (Z_TYPE_P(tmp) == IS_TRUE ||
 		     (Z_TYPE_P(tmp) == IS_LONG && Z_LVAL_P(tmp) == 1))) {
@@ -2233,12 +2239,29 @@ PHP_METHOD(SoapClient, __construct)
 		}
 		if ((tmp = zend_hash_str_find(ht, "classmap", sizeof("classmap")-1)) != NULL &&
 			Z_TYPE_P(tmp) == IS_ARRAY) {
-			if (UNEXPECTED(HT_IS_PACKED(Z_ARRVAL_P(tmp)))) {
-				php_error_docref(NULL, E_ERROR, "'classmap' option must be an associative array");
-			}
 			ZVAL_COPY(Z_CLIENT_CLASSMAP_P(this_ptr), tmp);
 		}

+		if ((tmp = zend_hash_str_find(ht, "stream_context", sizeof("stream_context")-1)) != NULL &&
+				Z_TYPE_P(tmp) == IS_RESOURCE) {
+			context = php_stream_context_from_zval(tmp, 1);
+			Z_ADDREF_P(tmp);
+		} else {
+			context = php_stream_context_alloc();
+		}
+
+		if ((tmp = zend_hash_str_find(ht, "local_cert", sizeof("local_cert")-1)) != NULL &&
+		    Z_TYPE_P(tmp) == IS_STRING) {
+			if (!context) {
+				context = php_stream_context_alloc();
+			}
+			php_stream_context_set_option(context, "ssl", "local_cert", tmp);
+			if ((tmp = zend_hash_str_find(ht, "passphrase", sizeof("passphrase")-1)) != NULL &&
+			    Z_TYPE_P(tmp) == IS_STRING) {
+				php_stream_context_set_option(context, "ssl", "passphrase", tmp);
+			}
+		}
+
 		if ((tmp = zend_hash_str_find(ht, "typemap", sizeof("typemap")-1)) != NULL &&
 			Z_TYPE_P(tmp) == IS_ARRAY &&
 			zend_hash_num_elements(Z_ARRVAL_P(tmp)) > 0) {
@@ -2313,6 +2336,7 @@ PHP_METHOD(SoapClient, __construct)
 	if (typemap_ht) {
 		soap_client_object_fetch(Z_OBJ_P(this_ptr))->typemap = soap_create_typemap(sdl, typemap_ht);
 	}
+
 	SOAP_CLIENT_END_CODE();
 }
 /* }}} */
diff --git a/ext/soap/tests/bugs/gh16256.phpt b/ext/soap/tests/bugs/gh16256.phpt
index ce2ba131406..a6d5f3fbbf3 100644
--- a/ext/soap/tests/bugs/gh16256.phpt
+++ b/ext/soap/tests/bugs/gh16256.phpt
@@ -20,5 +20,5 @@
 }
 ?>
 --EXPECT--
-SoapClient::__construct(): 'classmap' option must be an associative array
+SoapClient::__construct(): Argument #2 ($options) "classmap" option must be an associative array
 SoapServer::__construct(): Argument #2 ($options) "classmap" option must be an associative array
diff --git a/ext/soap/tests/classmap_invalid_keys.phpt b/ext/soap/tests/classmap_invalid_keys.phpt
new file mode 100644
index 00000000000..b30633dd004
--- /dev/null
+++ b/ext/soap/tests/classmap_invalid_keys.phpt
@@ -0,0 +1,68 @@
+--TEST--
+SoapClient and SoapServer classmap options must only contain string keys
+--EXTENSIONS--
+soap
+--FILE--
+<?php
+
+$emptyHash = ['type' => 'stdClass'];
+unset($emptyHash['type']);
+
+$cases = [
+    'empty' => [],
+    'empty hash' => $emptyHash,
+    'packed' => ['stdClass'],
+    'sparse numeric' => [100 => 'stdClass'],
+    'numeric string' => ['1' => 'stdClass'],
+    'mixed' => ['type' => 'stdClass', 1 => 'stdClass'],
+    'associative' => ['type' => 'stdClass'],
+];
+
+foreach ($cases as $name => $classmap) {
+    echo "-- $name --\n";
+
+    try {
+        new SoapClient(null, [
+            'location' => 'http://example.com/',
+            'uri' => 'urn:test',
+            'classmap' => $classmap,
+        ]);
+        echo "SoapClient: OK\n";
+    } catch (Throwable $e) {
+        echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+    }
+
+    try {
+        new SoapServer(null, [
+            'uri' => 'urn:test',
+            'classmap' => $classmap,
+        ]);
+        echo "SoapServer: OK\n";
+    } catch (Throwable $e) {
+        echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+    }
+}
+
+?>
+--EXPECT--
+-- empty --
+SoapClient: OK
+SoapServer: OK
+-- empty hash --
+SoapClient: OK
+SoapServer: OK
+-- packed --
+ValueError: SoapClient::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+ValueError: SoapServer::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+-- sparse numeric --
+ValueError: SoapClient::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+ValueError: SoapServer::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+-- numeric string --
+ValueError: SoapClient::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+ValueError: SoapServer::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+-- mixed --
+ValueError: SoapClient::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+ValueError: SoapServer::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+-- associative --
+SoapClient: OK
+SoapServer: OK
diff --git a/ext/soap/tests/classmap_invalid_keys_error_types.phpt b/ext/soap/tests/classmap_invalid_keys_error_types.phpt
new file mode 100644
index 00000000000..57b73f09d72
--- /dev/null
+++ b/ext/soap/tests/classmap_invalid_keys_error_types.phpt
@@ -0,0 +1,32 @@
+--TEST--
+SoapClient and SoapServer report an invalid classmap option as ValueError
+--EXTENSIONS--
+soap
+--FILE--
+<?php
+
+$classmap = ['type' => 'stdClass', 1 => 'stdClass'];
+
+try {
+    new SoapClient(null, [
+        'location' => 'http://example.com/',
+        'uri' => 'urn:test',
+        'classmap' => $classmap,
+    ]);
+} catch (Throwable $e) {
+    echo 'SoapClient: ', $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+try {
+    new SoapServer(null, [
+        'uri' => 'urn:test',
+        'classmap' => $classmap,
+    ]);
+} catch (Throwable $e) {
+    echo 'SoapServer: ', $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+SoapClient: ValueError: SoapClient::__construct(): Argument #2 ($options) "classmap" option must be an associative array
+SoapServer: ValueError: SoapServer::__construct(): Argument #2 ($options) "classmap" option must be an associative array
diff --git a/ext/soap/tests/classmap_invalid_keys_exceptions_disabled.phpt b/ext/soap/tests/classmap_invalid_keys_exceptions_disabled.phpt
new file mode 100644
index 00000000000..8340a5dbf78
--- /dev/null
+++ b/ext/soap/tests/classmap_invalid_keys_exceptions_disabled.phpt
@@ -0,0 +1,21 @@
+--TEST--
+SoapClient reports an invalid classmap option as ValueError when exceptions are disabled
+--EXTENSIONS--
+soap
+--FILE--
+<?php
+
+try {
+    new SoapClient(null, [
+        'location' => 'http://example.com/',
+        'uri' => 'urn:test',
+        'exceptions' => false,
+        'classmap' => ['type' => 'stdClass', 1 => 'stdClass'],
+    ]);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+ValueError: SoapClient::__construct(): Argument #2 ($options) "classmap" option must be an associative array
diff --git a/ext/soap/tests/classmap_invalid_type.phpt b/ext/soap/tests/classmap_invalid_type.phpt
new file mode 100644
index 00000000000..2e6300aec8a
--- /dev/null
+++ b/ext/soap/tests/classmap_invalid_type.phpt
@@ -0,0 +1,30 @@
+--TEST--
+SoapClient and SoapServer classmap options must be arrays
+--EXTENSIONS--
+soap
+--FILE--
+<?php
+
+try {
+    new SoapClient(null, [
+        'location' => 'http://example.com/',
+        'uri' => 'urn:test',
+        'classmap' => 1,
+    ]);
+} catch (Throwable $e) {
+    echo 'SoapClient: ', $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+try {
+    new SoapServer(null, [
+        'uri' => 'urn:test',
+        'classmap' => 1,
+    ]);
+} catch (Throwable $e) {
+    echo 'SoapServer: ', $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+SoapClient: TypeError: SoapClient::__construct(): Argument #2 ($options) "classmap" option must be of type array, int given
+SoapServer: TypeError: SoapServer::__construct(): Argument #2 ($options) "classmap" option must be of type array, int given
diff --git a/ext/soap/tests/classmap_private_property_packed_array.phpt b/ext/soap/tests/classmap_private_property_packed_array.phpt
new file mode 100644
index 00000000000..058483d2dc2
--- /dev/null
+++ b/ext/soap/tests/classmap_private_property_packed_array.phpt
@@ -0,0 +1,45 @@
+--TEST--
+SoapClient must not read packed private classmap as string-keyed map
+--EXTENSIONS--
+soap
+--FILE--
+<?php
+
+class LocalSoapClient extends SoapClient {
+    public function __doRequest($request, $location, $action, $version, $one_way = false, ?string $uriParserClass = null): string {
+        echo "__doRequest called\n";
+
+        return <<<'XML'
+<?xml version="1.0" encoding="UTF-8"?>
+<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
+  <SOAP-ENV:Body>
+    <SOAP-ENV:Fault>
+      <faultcode>SOAP-ENV:Server</faultcode>
+      <faultstring>expected fault</faultstring>
+    </SOAP-ENV:Fault>
+  </SOAP-ENV:Body>
+</SOAP-ENV:Envelope>
+XML;
+    }
+}
+
+class Foo {}
+
+$client = new LocalSoapClient(null, [
+    'location' => 'http://example.org/',
+    'uri' => 'http://example.org/',
+]);
+
+$property = new ReflectionProperty(SoapClient::class, '_classmap');
+$property->setValue($client, ['Foo']);
+
+try {
+    $client->__soapCall('foo', [new Foo()]);
+} catch (SoapFault) {
+    echo "SOAP Fault thrown\n";
+}
+
+?>
+--EXPECT--
+__doRequest called
+SOAP Fault thrown