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