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