Commit 5c163caf2bd for php.net

commit 5c163caf2bdb5587fa10c705efb12bdc2ebb4bb6
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Wed Jun 24 07:47:09 2026 -0400

    Fix GH-17976: CRLF injection via from and user_agent in HTTP wrapper (#21658)

    The from and user_agent INI settings and the user_agent stream context
    option were written into HTTP request headers without stripping CR/LF
    characters, allowing header injection.

    Truncate at the first \r or \n and emit E_WARNING. The from and
    user_agent globals are stored as zend_string so the check scans the
    full length and a NUL byte no longer pre-truncates the value before the
    CR/LF scan.

    Fixes GH-17976

diff --git a/ext/soap/php_http.c b/ext/soap/php_http.c
index e8433e7c4f6..02874c5c758 100644
--- a/ext/soap/php_http.c
+++ b/ext/soap/php_http.c
@@ -626,7 +626,7 @@ int make_http_soap_request(
 			}
 		} else if (FG(user_agent)) {
 			smart_str_append_const(&soap_headers, "User-Agent: ");
-			smart_str_appends(&soap_headers, FG(user_agent));
+			smart_str_append(&soap_headers, FG(user_agent));
 			smart_str_append_const(&soap_headers, "\r\n");
 		} else {
 			smart_str_append_const(&soap_headers, "User-Agent: PHP-SOAP/"PHP_VERSION"\r\n");
diff --git a/ext/standard/file.c b/ext/standard/file.c
index db0fc45385d..f9474af6873 100644
--- a/ext/standard/file.c
+++ b/ext/standard/file.c
@@ -147,8 +147,8 @@ static PHP_INI_MH(OnUpdateAutoDetectLineEndings)
 }

 PHP_INI_BEGIN()
-	STD_PHP_INI_ENTRY("user_agent", NULL, PHP_INI_ALL, OnUpdateString, user_agent, php_file_globals, file_globals)
-	STD_PHP_INI_ENTRY("from", NULL, PHP_INI_ALL, OnUpdateString, from_address, php_file_globals, file_globals)
+	STD_PHP_INI_ENTRY("user_agent", NULL, PHP_INI_ALL, OnUpdateStr, user_agent, php_file_globals, file_globals)
+	STD_PHP_INI_ENTRY("from", NULL, PHP_INI_ALL, OnUpdateStr, from_address, php_file_globals, file_globals)
 	STD_PHP_INI_ENTRY("default_socket_timeout", "60", PHP_INI_ALL, OnUpdateLong, default_socket_timeout, php_file_globals, file_globals)
 	STD_PHP_INI_BOOLEAN("auto_detect_line_endings", "0", PHP_INI_ALL, OnUpdateAutoDetectLineEndings, auto_detect_line_endings, php_file_globals, file_globals)
 PHP_INI_END()
diff --git a/ext/standard/file.h b/ext/standard/file.h
index ac218169599..9ba5f5b8b93 100644
--- a/ext/standard/file.h
+++ b/ext/standard/file.h
@@ -93,8 +93,8 @@ typedef struct {
 	size_t def_chunk_size;
 	bool auto_detect_line_endings;
 	zend_long default_socket_timeout;
-	char *user_agent; /* for the http wrapper */
-	char *from_address; /* for the ftp and http wrappers */
+	zend_string *user_agent; /* for the http wrapper */
+	zend_string *from_address; /* for the ftp and http wrappers */
 	const char *user_stream_current_filename; /* for simple recursion protection */
 	php_stream_context *default_context;
 	HashTable *stream_wrappers;			/* per-request copy of url_stream_wrappers_hash */
diff --git a/ext/standard/ftp_fopen_wrapper.c b/ext/standard/ftp_fopen_wrapper.c
index f99ae5e4b4e..457d1410aab 100644
--- a/ext/standard/ftp_fopen_wrapper.c
+++ b/ext/standard/ftp_fopen_wrapper.c
@@ -273,7 +273,7 @@ static php_stream *php_ftp_fopen_connect(php_stream_wrapper *wrapper, const char
 			/* if the user has configured who they are,
 			   send that as the password */
 			if (FG(from_address)) {
-				php_stream_printf(stream, "PASS %s\r\n", FG(from_address));
+				php_stream_printf(stream, "PASS %s\r\n", ZSTR_VAL(FG(from_address)));
 			} else {
 				php_stream_write_string(stream, "PASS anonymous\r\n");
 			}
diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c
index 9e3db671604..195394c7ed8 100644
--- a/ext/standard/http_fopen_wrapper.c
+++ b/ext/standard/http_fopen_wrapper.c
@@ -351,6 +351,23 @@ static zend_string *php_stream_http_response_headers_parse(php_stream_wrapper *w
 	return NULL;
 }

+static inline void smart_str_append_header_value(smart_str *dest, const zend_string *value, 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++;
+	}
+	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 php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper,
 		const char *path, const char *mode, int options, zend_string **opened_path,
 		php_stream_context *context, int redirect_max, int flags,
@@ -361,7 +378,7 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper,
 	int use_ssl;
 	int use_proxy = 0;
 	zend_string *tmp = NULL;
-	char *ua_str = NULL;
+	zend_string *ua_str = NULL;
 	zval *ua_zval = NULL, *tmpzval = NULL, ssl_proxy_peer_name;
 	int reqok = 0;
 	char *http_header_line = NULL;
@@ -788,7 +805,7 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper,
 	/* if the user has configured who they are, send a From: line */
 	if (!(have_header & HTTP_HEADER_FROM) && FG(from_address)) {
 		smart_str_appends(&req_buf, "From: ");
-		smart_str_appends(&req_buf, FG(from_address));
+		smart_str_append_header_value(&req_buf, FG(from_address), "From");
 		smart_str_appends(&req_buf, "\r\n");
 	}

@@ -817,30 +834,15 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper,
 	if (context &&
 	    (ua_zval = php_stream_context_get_option(context, "http", "user_agent")) != NULL &&
 		Z_TYPE_P(ua_zval) == IS_STRING) {
-		ua_str = Z_STRVAL_P(ua_zval);
+		ua_str = Z_STR_P(ua_zval);
 	} else if (FG(user_agent)) {
 		ua_str = FG(user_agent);
 	}

-	if (((have_header & HTTP_HEADER_USER_AGENT) == 0) && ua_str) {
-#define _UA_HEADER "User-Agent: %s\r\n"
-		char *ua;
-		size_t ua_len;
-
-		ua_len = sizeof(_UA_HEADER) + strlen(ua_str);
-
-		/* ensure the header is only sent if user_agent is not blank */
-		if (ua_len > sizeof(_UA_HEADER)) {
-			ua = emalloc(ua_len + 1);
-			if ((ua_len = slprintf(ua, ua_len, _UA_HEADER, ua_str)) > 0) {
-				ua[ua_len] = 0;
-				smart_str_appendl(&req_buf, ua, ua_len);
-			} else {
-				php_stream_wrapper_warn_nt(wrapper, context, options, InvalidHeader,
-					"Cannot construct User-agent header");
-			}
-			efree(ua);
-		}
+	if (((have_header & HTTP_HEADER_USER_AGENT) == 0) && ua_str && ZSTR_LEN(ua_str)) {
+		smart_str_appends(&req_buf, "User-Agent: ");
+		smart_str_append_header_value(&req_buf, ua_str, "User-Agent");
+		smart_str_appends(&req_buf, "\r\n");
 	}

 	if (user_headers) {
diff --git a/ext/standard/tests/http/gh17976.phpt b/ext/standard/tests/http/gh17976.phpt
new file mode 100644
index 00000000000..bba9a7a0fcf
--- /dev/null
+++ b/ext/standard/tests/http/gh17976.phpt
@@ -0,0 +1,65 @@
+--TEST--
+GH-17976 (CRLF injection via from and user_agent INI settings in HTTP wrapper)
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $ctxt = stream_context_create([
+        "socket" => ["tcp_nodelay" => true]
+    ]);
+
+    $server = stream_socket_server(
+        "tcp://127.0.0.1:0", $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctxt);
+    phpt_notify_server_start($server);
+
+    for ($i = 0; $i < 4; $i++) {
+        $conn = stream_socket_accept($server);
+        $result = fread($conn, 4096);
+        fwrite($conn, "HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\n" . base64_encode($result));
+        fclose($conn);
+    }
+CODE;
+
+$clientCode = <<<'CODE'
+    // Test 1: from INI with CRLF
+    ini_set("from", "test\r\nInjected-From: evil");
+    ini_set("user_agent", "clean_ua");
+    $raw = base64_decode(file_get_contents("http://{{ ADDR }}/"));
+    echo (str_contains($raw, "Injected-From:") ? "FAIL" : "OK") . ": from INI\n";
+
+    // Test 2: user_agent INI with CRLF
+    ini_restore("from");
+    ini_set("user_agent", "test\r\nInjected-UA: evil");
+    $raw = base64_decode(file_get_contents("http://{{ ADDR }}/"));
+    echo (str_contains($raw, "Injected-UA:") ? "FAIL" : "OK") . ": user_agent INI\n";
+
+    // Test 3: user_agent context option with CRLF
+    ini_restore("user_agent");
+    $ctx = stream_context_create(["http" => [
+        "user_agent" => "test\nInjected-Ctx: evil"
+    ]]);
+    $raw = base64_decode(file_get_contents("http://{{ ADDR }}/", false, $ctx));
+    echo (str_contains($raw, "Injected-Ctx:") ? "FAIL" : "OK") . ": user_agent context\n";
+
+    // Test 4: user_agent INI with a NUL byte before the CRLF
+    ini_set("user_agent", "ua\0valid\r\nInjected-Nul: evil");
+    $raw = base64_decode(file_get_contents("http://{{ ADDR }}/"));
+    echo (str_contains($raw, "Injected-Nul:") ? "FAIL" : "OK") . ": user_agent NUL+CRLF\n";
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECTF--
+Warning: file_get_contents(): Header From value contains newline characters and has been truncated in %s on line %d
+OK: from INI
+
+Warning: file_get_contents(): Header User-Agent value contains newline characters and has been truncated in %s on line %d
+OK: user_agent INI
+
+Warning: file_get_contents(): Header User-Agent value contains newline characters and has been truncated in %s on line %d
+OK: user_agent context
+
+Warning: file_get_contents(): Header User-Agent value contains newline characters and has been truncated in %s on line %d
+OK: user_agent NUL+CRLF