Commit f6fe838e364 for php.net

commit f6fe838e364943020daf802d8c9f55ef5099cf6f
Author: David Carlier <devnexen@gmail.com>
Date:   Fri Jul 17 12:20:39 2026 +0100

    ext/soap: truncate header values containing newline characters.

    Commit 3dcdb9826be routed the user_agent option through a truncate-and-warn
    check, but the Content-Type context option, the soapaction and the cookie
    names and values reach the same request buffer unchecked, so a CR/LF in any
    of them still injects arbitrary request headers.

    Emit all of them through that same check. The helper now takes an explicit
    length so the char* soapaction can share it.

    Close GH-22781

diff --git a/NEWS b/NEWS
index de11135f0d3..fd5b2d859ee 100644
--- a/NEWS
+++ b/NEWS
@@ -40,6 +40,10 @@ PHP                                                                        NEWS
   . Fixed bug GH-22681 (Reflection*::__toString() truncates on null bytes).
     (DanielEScherzer)

+- SOAP:
+  . Fixed header injection through the Content-Type context option, the
+    soapaction and the cookie names and values. (David Carlier)
+
 - Sockets:
   . Fixed socket_set_option() validation error messages for UDP_SEGMENT and
     TCP_USER_TIMEOUT, and SO_LINGER options. (Weilin Du)
diff --git a/ext/soap/php_http.c b/ext/soap/php_http.c
index 22c18259d68..b49acd947b3 100644
--- a/ext/soap/php_http.c
+++ b/ext/soap/php_http.c
@@ -25,23 +25,24 @@ static zend_string *get_http_headers(php_stream *socketd);
 #define smart_str_append_const(str, const) \
 	smart_str_appendl(str,const,sizeof(const)-1)

-static void soap_smart_str_append_header_value(smart_str *dest, const zend_string *value, const char *header_name)
+static void soap_smart_str_append_header_value_ex(smart_str *dest, const char *src, size_t len, const char *header_name)
 {
-	const char *src = ZSTR_VAL(value);
-	size_t len = ZSTR_LEN(value);
 	size_t i = 0;
 	while (i < len && src[i] != '\r' && src[i] != '\n') {
 		i++;
 	}
+	smart_str_appendl(dest, src, i);
 	if (i < len) {
-		smart_str_appendl(dest, src, i);
 		php_error_docref(NULL, E_WARNING,
 			"Header %s value contains newline characters and has been truncated", header_name);
-	} else {
-		smart_str_append(dest, value);
 	}
 }

+static void soap_smart_str_append_header_value(smart_str *dest, const zend_string *value, const char *header_name)
+{
+	soap_smart_str_append_header_value_ex(dest, ZSTR_VAL(value), ZSTR_LEN(value), header_name);
+}
+
 /* Proxy HTTP Authentication */
 bool proxy_authentication(zval* this_ptr, smart_str* soap_headers)
 {
@@ -656,13 +657,13 @@ bool make_http_soap_request(
 				Z_STRLEN_P(tmp) > 0
 			) {
 				smart_str_append_const(&soap_headers, "Content-Type: ");
-				smart_str_append(&soap_headers, Z_STR_P(tmp));
+				soap_smart_str_append_header_value(&soap_headers, Z_STR_P(tmp), "Content-Type");
 			} else {
 				smart_str_append_const(&soap_headers, "Content-Type: application/soap+xml; charset=utf-8");
 			}
 			if (soapaction) {
 				smart_str_append_const(&soap_headers,"; action=\"");
-				smart_str_appends(&soap_headers, soapaction);
+				soap_smart_str_append_header_value_ex(&soap_headers, soapaction, strlen(soapaction), "SOAPAction");
 				smart_str_append_const(&soap_headers,"\"");
 			}
 			smart_str_append_const(&soap_headers,"\r\n");
@@ -673,14 +674,14 @@ bool make_http_soap_request(
 				Z_STRLEN_P(tmp) > 0
 			) {
 				smart_str_append_const(&soap_headers, "Content-Type: ");
-				smart_str_append(&soap_headers, Z_STR_P(tmp));
+				soap_smart_str_append_header_value(&soap_headers, Z_STR_P(tmp), "Content-Type");
 				smart_str_append_const(&soap_headers, "\r\n");
 			} else {
 				smart_str_append_const(&soap_headers, "Content-Type: text/xml; charset=utf-8\r\n");
 			}
 			if (soapaction) {
 				smart_str_append_const(&soap_headers, "SOAPAction: \"");
-				smart_str_appends(&soap_headers, soapaction);
+				soap_smart_str_append_header_value_ex(&soap_headers, soapaction, strlen(soapaction), "SOAPAction");
 				smart_str_append_const(&soap_headers, "\"\r\n");
 			}
 		}
@@ -892,9 +893,9 @@ bool make_http_soap_request(
 								smart_str_appends(&soap_headers, "; ");
 							}
 							first_cookie = false;
-							smart_str_append(&soap_headers, key);
+							soap_smart_str_append_header_value(&soap_headers, key, "Cookie");
 							smart_str_appendc(&soap_headers, '=');
-							smart_str_append(&soap_headers, Z_STR_P(value));
+							soap_smart_str_append_header_value(&soap_headers, Z_STR_P(value), "Cookie");
 						}
 					}
 				}
diff --git a/ext/soap/tests/content_type_header_injection.phpt b/ext/soap/tests/content_type_header_injection.phpt
new file mode 100644
index 00000000000..d04cf337164
--- /dev/null
+++ b/ext/soap/tests/content_type_header_injection.phpt
@@ -0,0 +1,62 @@
+--TEST--
+SoapClient must truncate a content_type context option that contains newline characters
+--EXTENSIONS--
+soap
+--SKIPIF--
+<?php
+    if (!file_exists(__DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc")) {
+        echo "skip sapi/cli/tests/php_cli_server.inc required but not found";
+    }
+?>
+--FILE--
+<?php
+
+include __DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc";
+
+$args = ["-d", "extension_dir=" . ini_get("extension_dir"), "-d", "extension=" . (substr(PHP_OS, 0, 3) == "WIN" ? "php_" : "") . "soap." . PHP_SHLIB_SUFFIX];
+if (php_ini_loaded_file()) {
+  $args[] = "-c";
+  $args[] = php_ini_loaded_file();
+}
+$code = <<<'PHP'
+$content = trim(file_get_contents("php://input")) . PHP_EOL;
+PHP;
+
+php_cli_server_start($code, null, $args);
+
+$context = stream_context_create([
+  'http' => ['content_type' => "text/xml\r\nX-Injected: yes"],
+]);
+
+foreach ([SOAP_1_1, SOAP_1_2] as $version) {
+  $client = new SoapClient(NULL, [
+    'location' => 'http://' . PHP_CLI_SERVER_ADDRESS,
+    'uri' => 'misc-uri',
+    'trace' => true,
+    'soap_version' => $version,
+    'stream_context' => $context,
+  ]);
+
+  $client->__soapCall("foo", []);
+  echo $client->__getLastRequestHeaders();
+}
+
+?>
+--EXPECTF--
+Warning: SoapClient::__doRequest(): Header Content-Type value contains newline characters and has been truncated in %s on line %d
+POST / HTTP/1.1
+Host: localhost:%d
+Connection: Keep-Alive
+User-Agent: PHP-SOAP/%s
+Content-Type: text/xml
+SOAPAction: "misc-uri#foo"
+Content-Length: %d
+
+
+Warning: SoapClient::__doRequest(): Header Content-Type value contains newline characters and has been truncated in %s on line %d
+POST / HTTP/1.1
+Host: localhost:%d
+Connection: Keep-Alive
+User-Agent: PHP-SOAP/%s
+Content-Type: text/xml; action="misc-uri#foo"
+Content-Length: %d
diff --git a/ext/soap/tests/cookie_header_injection.phpt b/ext/soap/tests/cookie_header_injection.phpt
new file mode 100644
index 00000000000..880050e84d0
--- /dev/null
+++ b/ext/soap/tests/cookie_header_injection.phpt
@@ -0,0 +1,52 @@
+--TEST--
+SoapClient must truncate a cookie name or value that contains newline characters
+--EXTENSIONS--
+soap
+--SKIPIF--
+<?php
+    if (!file_exists(__DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc")) {
+        echo "skip sapi/cli/tests/php_cli_server.inc required but not found";
+    }
+?>
+--FILE--
+<?php
+
+include __DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc";
+
+$args = ["-d", "extension_dir=" . ini_get("extension_dir"), "-d", "extension=" . (substr(PHP_OS, 0, 3) == "WIN" ? "php_" : "") . "soap." . PHP_SHLIB_SUFFIX];
+if (php_ini_loaded_file()) {
+  $args[] = "-c";
+  $args[] = php_ini_loaded_file();
+}
+$code = <<<'PHP'
+$content = trim(file_get_contents("php://input")) . PHP_EOL;
+PHP;
+
+php_cli_server_start($code, null, $args);
+
+$client = new SoapClient(NULL, [
+  'location' => 'http://' . PHP_CLI_SERVER_ADDRESS,
+  'uri' => 'misc-uri',
+  'trace' => true,
+]);
+
+$client->__setCookie("evil-value", "a\r\nX-Injected: yes");
+$client->__setCookie("evil-name\r\nX-Injected: yes", "b");
+$client->__setCookie("harmless", "c");
+
+$client->__soapCall("foo", []);
+echo $client->__getLastRequestHeaders();
+
+?>
+--EXPECTF--
+Warning: SoapClient::__doRequest(): Header Cookie value contains newline characters and has been truncated in %s on line %d
+
+Warning: SoapClient::__doRequest(): Header Cookie value contains newline characters and has been truncated in %s on line %d
+POST / HTTP/1.1
+Host: localhost:%d
+Connection: Keep-Alive
+User-Agent: PHP-SOAP/%s
+Content-Type: text/xml; charset=utf-8
+SOAPAction: "misc-uri#foo"
+Content-Length: %d
+Cookie: evil-value=a; evil-name=b; harmless=c
diff --git a/ext/soap/tests/soapaction_header_injection.phpt b/ext/soap/tests/soapaction_header_injection.phpt
new file mode 100644
index 00000000000..996c193624f
--- /dev/null
+++ b/ext/soap/tests/soapaction_header_injection.phpt
@@ -0,0 +1,57 @@
+--TEST--
+SoapClient must truncate a soapaction that contains newline characters
+--EXTENSIONS--
+soap
+--SKIPIF--
+<?php
+    if (!file_exists(__DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc")) {
+        echo "skip sapi/cli/tests/php_cli_server.inc required but not found";
+    }
+?>
+--FILE--
+<?php
+
+include __DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc";
+
+$args = ["-d", "extension_dir=" . ini_get("extension_dir"), "-d", "extension=" . (substr(PHP_OS, 0, 3) == "WIN" ? "php_" : "") . "soap." . PHP_SHLIB_SUFFIX];
+if (php_ini_loaded_file()) {
+  $args[] = "-c";
+  $args[] = php_ini_loaded_file();
+}
+$code = <<<'PHP'
+$content = trim(file_get_contents("php://input")) . PHP_EOL;
+PHP;
+
+php_cli_server_start($code, null, $args);
+
+foreach ([SOAP_1_1, SOAP_1_2] as $version) {
+  $client = new SoapClient(NULL, [
+    'location' => 'http://' . PHP_CLI_SERVER_ADDRESS,
+    'uri' => 'misc-uri',
+    'trace' => true,
+    'soap_version' => $version,
+  ]);
+
+  $client->__soapCall("foo", [], ['soapaction' => "an-action\r\nX-Injected: yes"]);
+  echo $client->__getLastRequestHeaders();
+}
+
+?>
+--EXPECTF--
+Warning: SoapClient::__doRequest(): Header SOAPAction value contains newline characters and has been truncated in %s on line %d
+POST / HTTP/1.1
+Host: localhost:%d
+Connection: Keep-Alive
+User-Agent: PHP-SOAP/%s
+Content-Type: text/xml; charset=utf-8
+SOAPAction: "an-action"
+Content-Length: %d
+
+
+Warning: SoapClient::__doRequest(): Header SOAPAction value contains newline characters and has been truncated in %s on line %d
+POST / HTTP/1.1
+Host: localhost:%d
+Connection: Keep-Alive
+User-Agent: PHP-SOAP/%s
+Content-Type: application/soap+xml; charset=utf-8; action="an-action"
+Content-Length: %d