From e81d0cd14bfeb17e899c73e3aece4991bbda76af Mon Sep 17 00:00:00 2001
From: Jakub Zelenka <bukka@php.net>
Date: Sun, 19 Jan 2025 17:49:53 +0100
Subject: [PATCH 02/11] Fix GHSA-pcmh-g36c-qc44: http headers without colon
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The header line must contain colon otherwise it is invalid and it needs
to fail.

Reviewed-by: Tim Düsterhus <tim@tideways-gmbh.com>
(cherry picked from commit 0548c4c1756724a89ef8310709419b08aadb2b3b)
---
 ext/standard/http_fopen_wrapper.c             | 51 ++++++++++++++-----
 ext/standard/tests/http/bug47021.phpt         | 22 ++++----
 ext/standard/tests/http/bug75535.phpt         |  4 +-
 .../tests/http/ghsa-pcmh-g36c-qc44-001.phpt   | 51 +++++++++++++++++++
 .../tests/http/ghsa-pcmh-g36c-qc44-002.phpt   | 51 +++++++++++++++++++
 5 files changed, 154 insertions(+), 25 deletions(-)
 create mode 100644 ext/standard/tests/http/ghsa-pcmh-g36c-qc44-001.phpt
 create mode 100644 ext/standard/tests/http/ghsa-pcmh-g36c-qc44-002.phpt

diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c
index bfc88a74545..7ee22b85f88 100644
--- a/ext/standard/http_fopen_wrapper.c
+++ b/ext/standard/http_fopen_wrapper.c
@@ -117,6 +117,7 @@ static zend_bool check_has_header(const char *headers, const char *header) {
 typedef struct _php_stream_http_response_header_info {
 	php_stream_filter *transfer_encoding;
 	size_t file_size;
+	bool error;
 	bool follow_location;
 	char location[HTTP_HEADER_BLOCK_SIZE];
 } php_stream_http_response_header_info;
@@ -126,6 +127,7 @@ static void php_stream_http_response_header_info_init(
 {
 	header_info->transfer_encoding = NULL;
 	header_info->file_size = 0;
+	header_info->error = false;
 	header_info->follow_location = 1;
 	header_info->location[0] = '\0';
 }
@@ -163,10 +165,11 @@ static bool php_stream_http_response_header_trim(char *http_header_line,
 /* Process folding headers of the current line and if there are none, parse last full response
  * header line. It returns NULL if the last header is finished, otherwise it returns updated
  * last header line.  */
-static zend_string *php_stream_http_response_headers_parse(php_stream *stream,
-		php_stream_context *context, int options, zend_string *last_header_line_str,
-		char *header_line, size_t *header_line_length, int response_code,
-		zval *response_header, php_stream_http_response_header_info *header_info)
+static zend_string *php_stream_http_response_headers_parse(php_stream_wrapper *wrapper,
+		php_stream *stream, php_stream_context *context, int options,
+		zend_string *last_header_line_str, char *header_line, size_t *header_line_length,
+		int response_code, zval *response_header,
+		php_stream_http_response_header_info *header_info)
 {
 	char *last_header_line = ZSTR_VAL(last_header_line_str);
 	size_t last_header_line_length = ZSTR_LEN(last_header_line_str);
@@ -208,6 +211,19 @@ static zend_string *php_stream_http_response_headers_parse(php_stream *stream,
 	/* Find header separator position. */
 	char *last_header_value = memchr(last_header_line, ':', last_header_line_length);
 	if (last_header_value) {
+		/* Verify there is no space in header name */
+		char *last_header_name = last_header_line + 1;
+		while (last_header_name < last_header_value) {
+			if (*last_header_name == ' ' || *last_header_name == '\t') {
+				header_info->error = true;
+				php_stream_wrapper_log_error(wrapper, options,
+					"HTTP invalid response format (space in header name)!");
+				zend_string_efree(last_header_line_str);
+				return NULL;
+			}
+			++last_header_name;
+		}
+
 		last_header_value++; /* Skip ':'. */
 
 		/* Strip leading whitespace. */
@@ -216,9 +232,12 @@ static zend_string *php_stream_http_response_headers_parse(php_stream *stream,
 			last_header_value++;
 		}
 	} else {
-		/* There is no colon. Set the value to the end of the header line, which is effectively
-		 * an empty string. */
-		last_header_value = last_header_line_end;
+		/* There is no colon which means invalid response so error. */
+		header_info->error = true;
+		php_stream_wrapper_log_error(wrapper, options,
+				"HTTP invalid response format (no colon in header line)!");
+		zend_string_efree(last_header_line_str);
+		return NULL;
 	}
 
 	bool store_header = true;
@@ -928,10 +947,16 @@ finish:
 			
 			if (last_header_line_str != NULL) {
 				/* Parse last header line. */
-				last_header_line_str = php_stream_http_response_headers_parse(stream, context,
-						options, last_header_line_str, http_header_line, &http_header_line_length,
-						response_code, response_header, &header_info);
-				if (last_header_line_str != NULL) {
+				last_header_line_str = php_stream_http_response_headers_parse(wrapper, stream,
+						context, options, last_header_line_str, http_header_line,
+						&http_header_line_length, response_code, response_header, &header_info);
+				if (EXPECTED(last_header_line_str == NULL)) {
+					if (UNEXPECTED(header_info.error)) {
+						php_stream_close(stream);
+						stream = NULL;
+						goto out;
+					}
+				} else {
 					/* Folding header present so continue. */
 					continue;
 				}
@@ -961,8 +986,8 @@ finish:
 
 	/* If the stream was closed early, we still want to process the last line to keep BC. */
 	if (last_header_line_str != NULL) {
-		php_stream_http_response_headers_parse(stream, context, options, last_header_line_str,
-				NULL, NULL, response_code, response_header, &header_info);
+		php_stream_http_response_headers_parse(wrapper, stream, context, options,
+				last_header_line_str, NULL, NULL, response_code, response_header, &header_info);
 	}
 
 	if (!reqok || (header_info.location[0] != '\0' && header_info.follow_location)) {
diff --git a/ext/standard/tests/http/bug47021.phpt b/ext/standard/tests/http/bug47021.phpt
index 326eceb687a..168721f4ec1 100644
--- a/ext/standard/tests/http/bug47021.phpt
+++ b/ext/standard/tests/http/bug47021.phpt
@@ -70,23 +70,27 @@ do_test(1, true);
 echo "\n";
 
 ?>
---EXPECT--
+--EXPECTF--
+
 Type='text/plain'
 Hello
-Size=5
-World
+
+Warning: file_get_contents(http://%s:%d): Failed to open stream: HTTP invalid response format (no colon in header line)! in %s
+
 
 Type='text/plain'
 Hello
-Size=5
-World
+
+Warning: file_get_contents(http://%s:%d): Failed to open stream: HTTP invalid response format (no colon in header line)! in %s
+
 
 Type='text/plain'
 Hello
-Size=5
-World
+
+Warning: file_get_contents(http://%s:%d): Failed to open stream: HTTP invalid response format (no colon in header line)! in %s
+
 
 Type='text/plain'
 Hello
-Size=5
-World
+
+Warning: file_get_contents(http://%s:%d): Failed to open stream: HTTP invalid response format (no colon in header line)! in %s
diff --git a/ext/standard/tests/http/bug75535.phpt b/ext/standard/tests/http/bug75535.phpt
index 7b015890d2f..94348d1a027 100644
--- a/ext/standard/tests/http/bug75535.phpt
+++ b/ext/standard/tests/http/bug75535.phpt
@@ -21,9 +21,7 @@ http_server_kill($pid);
 
 --EXPECT--
 string(0) ""
-array(2) {
+array(1) {
   [0]=>
   string(15) "HTTP/1.0 200 Ok"
-  [1]=>
-  string(14) "Content-Length"
 }
diff --git a/ext/standard/tests/http/ghsa-pcmh-g36c-qc44-001.phpt b/ext/standard/tests/http/ghsa-pcmh-g36c-qc44-001.phpt
new file mode 100644
index 00000000000..bb7945ce62d
--- /dev/null
+++ b/ext/standard/tests/http/ghsa-pcmh-g36c-qc44-001.phpt
@@ -0,0 +1,51 @@
+--TEST--
+GHSA-pcmh-g36c-qc44: Header parser of http stream wrapper does not verify header name and colon (colon)
+--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);
+
+    $conn = stream_socket_accept($server);
+
+    phpt_notify(message:"server-accepted");
+
+    fwrite($conn, "HTTP/1.0 200 Ok\r\nContent-Type: text/html\r\nWrong-Header\r\nGood-Header: test\r\n\r\nbody\r\n");
+CODE;
+
+$clientCode = <<<'CODE'
+    function stream_notification_callback($notification_code, $severity, $message, $message_code, $bytes_transferred, $bytes_max) {
+    switch($notification_code) {
+        case STREAM_NOTIFY_MIME_TYPE_IS:
+            echo "Found the mime-type: ", $message, PHP_EOL;
+            break;
+        }
+    }
+
+    $ctx = stream_context_create();
+    stream_context_set_params($ctx, array("notification" => "stream_notification_callback"));
+    var_dump(file_get_contents("http://{{ ADDR }}", false, $ctx));
+    var_dump($http_response_header);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECTF--
+Found the mime-type: text/html
+
+Warning: file_get_contents(http://127.0.0.1:%d): Failed to open stream: HTTP invalid response format (no colon in header line)! in %s
+bool(false)
+array(2) {
+  [0]=>
+  string(15) "HTTP/1.0 200 Ok"
+  [1]=>
+  string(23) "Content-Type: text/html"
+}
diff --git a/ext/standard/tests/http/ghsa-pcmh-g36c-qc44-002.phpt b/ext/standard/tests/http/ghsa-pcmh-g36c-qc44-002.phpt
new file mode 100644
index 00000000000..1d0e4fa70a2
--- /dev/null
+++ b/ext/standard/tests/http/ghsa-pcmh-g36c-qc44-002.phpt
@@ -0,0 +1,51 @@
+--TEST--
+GHSA-pcmh-g36c-qc44: Header parser of http stream wrapper does not verify header name and colon (name)
+--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);
+
+    $conn = stream_socket_accept($server);
+
+    phpt_notify(message:"server-accepted");
+
+    fwrite($conn, "HTTP/1.0 200 Ok\r\nContent-Type: text/html\r\nWrong-Header : test\r\nGood-Header: test\r\n\r\nbody\r\n");
+CODE;
+
+$clientCode = <<<'CODE'
+    function stream_notification_callback($notification_code, $severity, $message, $message_code, $bytes_transferred, $bytes_max) {
+    switch($notification_code) {
+        case STREAM_NOTIFY_MIME_TYPE_IS:
+            echo "Found the mime-type: ", $message, PHP_EOL;
+            break;
+        }
+    }
+
+    $ctx = stream_context_create();
+    stream_context_set_params($ctx, array("notification" => "stream_notification_callback"));
+    var_dump(file_get_contents("http://{{ ADDR }}", false, $ctx));
+    var_dump($http_response_header);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECTF--
+Found the mime-type: text/html
+
+Warning: file_get_contents(http://127.0.0.1:%d): Failed to open stream: HTTP invalid response format (space in header name)! in %s
+bool(false)
+array(2) {
+  [0]=>
+  string(15) "HTTP/1.0 200 Ok"
+  [1]=>
+  string(23) "Content-Type: text/html"
+}
-- 
2.48.1

