From 382b1902c46ae1965b7973b801d007366a7851c2 Mon Sep 17 00:00:00 2001 From: Jakub Zelenka Date: Sun, 3 May 2026 20:01:41 +0200 Subject: [PATCH 5/6] GHSA-7qg2-v9fj-4mwv: [fpm] XSS within status endpoint Fixes GHSA-7qg2-v9fj-4mwv Fixes CVE-2026-6735 (cherry picked from commit 99a5ad7441de9914246c7863adb6997396008b9d) (cherry picked from commit cc2960e782eb5cc262d7bd572a7d18979a811954) (cherry picked from commit 62daef7b73108ceda2545862cde0673f252ba2d2) (cherry picked from commit aeaf48ca0bceba42b9595dff30d9e96029c54613) backport some new FPM tester features (cherry picked from commit 8b1746466f9fcf248f9879fabfa356106d365da0) (cherry picked from commit 8e0efa0f20484c8bbfdb8671d61b232b70e2bd0a) (cherry picked from commit 9be5be215954c010087134964d4fd3ab907cdbda) --- sapi/fpm/fpm/fpm_status.c | 31 +- sapi/fpm/tests/fcgi.inc | 192 ++- .../tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt | 48 + sapi/fpm/tests/logtool.inc | 476 ++++++ sapi/fpm/tests/response.inc | 281 ++++ sapi/fpm/tests/tester.inc | 1279 +++++++++++++++++ 6 files changed, 2244 insertions(+), 63 deletions(-) create mode 100644 sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt create mode 100644 sapi/fpm/tests/logtool.inc create mode 100644 sapi/fpm/tests/response.inc create mode 100644 sapi/fpm/tests/tester.inc diff --git a/sapi/fpm/fpm/fpm_status.c b/sapi/fpm/fpm/fpm_status.c index 666a1d9aba5..9693f0342ae 100644 --- a/sapi/fpm/fpm/fpm_status.c +++ b/sapi/fpm/fpm/fpm_status.c @@ -386,9 +386,10 @@ int fpm_status_handle_request(void) /* {{{ */ /* no need to test the var 'full' */ if (full_syntax) { - int i, first; - zend_string *tmp_query_string; - char *query_string; + unsigned int i; + int first; + zend_string *tmp_query_string, *tmp_request_uri_string; + char *query_string, *request_uri_string; struct timeval duration, now; #ifdef HAVE_FPM_LQ float cpu; @@ -415,13 +416,30 @@ int fpm_status_handle_request(void) /* {{{ */ } } + request_uri_string = NULL; + tmp_request_uri_string = NULL; + if (proc.request_uri[0] != '\0') { + if (encode) { + tmp_request_uri_string = php_escape_html_entities_ex( + (unsigned char*)proc.request_uri, + strlen(proc.request_uri), 1, ENT_DISALLOWED | ENT_HTML_DOC_XML1 | ENT_COMPAT, + NULL, /* double_encode */ 1); + request_uri_string = ZSTR_VAL(tmp_request_uri_string); + } else { + request_uri_string = proc.request_uri; + } + } + query_string = NULL; tmp_query_string = NULL; if (proc.query_string[0] != '\0') { if (!encode) { query_string = proc.query_string; } else { - tmp_query_string = php_escape_html_entities_ex((unsigned char *)proc.query_string, strlen(proc.query_string), 1, ENT_HTML_IGNORE_ERRORS & ENT_COMPAT, NULL, 1); + tmp_query_string = php_escape_html_entities_ex( + (unsigned char*)proc.query_string, + strlen(proc.query_string), 1, ENT_DISALLOWED | ENT_HTML_DOC_XML1 | ENT_COMPAT, + NULL, /* double_encode */ 1); query_string = ZSTR_VAL(tmp_query_string); } } @@ -449,7 +467,7 @@ int fpm_status_handle_request(void) /* {{{ */ proc.requests, duration.tv_sec * 1000000UL + duration.tv_usec, proc.request_method[0] != '\0' ? proc.request_method : "-", - proc.request_uri[0] != '\0' ? proc.request_uri : "-", + request_uri_string ? request_uri_string : "-", query_string ? "?" : "", query_string ? query_string : "", proc.content_length, @@ -462,6 +480,9 @@ int fpm_status_handle_request(void) /* {{{ */ PUTS(buffer); efree(buffer); + if (tmp_request_uri_string) { + zend_string_free(tmp_request_uri_string); + } if (tmp_query_string) { zend_string_free(tmp_query_string); } diff --git a/sapi/fpm/tests/fcgi.inc b/sapi/fpm/tests/fcgi.inc index b31676260da..07603a808e0 100644 --- a/sapi/fpm/tests/fcgi.inc +++ b/sapi/fpm/tests/fcgi.inc @@ -72,25 +72,25 @@ class Client /** * Socket - * @var Resource + * @var resource */ private $_sock = null; /** * Host - * @var String + * @var string */ private $_host = null; /** * Port - * @var Integer + * @var int */ private $_port = null; /** * Keep Alive - * @var Boolean + * @var bool */ private $_keepAlive = false; @@ -110,27 +110,27 @@ class Client /** * Use persistent sockets to connect to backend - * @var Boolean + * @var bool */ private $_persistentSocket = false; /** * Connect timeout in milliseconds - * @var Integer + * @var int */ private $_connectTimeout = 5000; /** * Read/Write timeout in milliseconds - * @var Integer + * @var int */ private $_readWriteTimeout = 5000; /** * Constructor * - * @param String $host Host of the FastCGI application - * @param Integer $port Port of the FastCGI application + * @param string $host Host of the FastCGI application + * @param int $port Port of the FastCGI application */ public function __construct($host, $port) { @@ -138,15 +138,25 @@ class Client $this->_port = $port; } + /** + * Get host. + * + * @return string + */ + public function getHost() + { + return $this->_host; + } + /** * Define whether or not the FastCGI application should keep the connection * alive at the end of a request * - * @param Boolean $b true if the connection should stay alive, false otherwise + * @param bool $b true if the connection should stay alive, false otherwise */ public function setKeepAlive($b) { - $this->_keepAlive = (boolean)$b; + $this->_keepAlive = (bool)$b; if (!$this->_keepAlive && $this->_sock) { fclose($this->_sock); } @@ -155,7 +165,7 @@ class Client /** * Get the keep alive status * - * @return Boolean true if the connection should stay alive, false otherwise + * @return bool true if the connection should stay alive, false otherwise */ public function getKeepAlive() { @@ -166,12 +176,12 @@ class Client * Define whether or not PHP should attempt to re-use sockets opened by previous * request for efficiency * - * @param Boolean $b true if persistent socket should be used, false otherwise + * @param bool $b true if persistent socket should be used, false otherwise */ public function setPersistentSocket($b) { $was_persistent = ($this->_sock && $this->_persistentSocket); - $this->_persistentSocket = (boolean)$b; + $this->_persistentSocket = (bool)$b; if (!$this->_persistentSocket && $was_persistent) { fclose($this->_sock); } @@ -180,7 +190,7 @@ class Client /** * Get the pesistent socket status * - * @return Boolean true if the socket should be persistent, false otherwise + * @return bool true if the socket should be persistent, false otherwise */ public function getPersistentSocket() { @@ -191,7 +201,7 @@ class Client /** * Set the connect timeout * - * @param Integer number of milliseconds before connect will timeout + * @param int number of milliseconds before connect will timeout */ public function setConnectTimeout($timeoutMs) { @@ -201,7 +211,7 @@ class Client /** * Get the connect timeout * - * @return Integer number of milliseconds before connect will timeout + * @return int number of milliseconds before connect will timeout */ public function getConnectTimeout() { @@ -211,7 +221,7 @@ class Client /** * Set the read/write timeout * - * @param Integer number of milliseconds before read or write call will timeout + * @param int number of milliseconds before read or write call will timeout */ public function setReadWriteTimeout($timeoutMs) { @@ -222,7 +232,7 @@ class Client /** * Get the read timeout * - * @return Integer number of milliseconds before read will timeout + * @return int number of milliseconds before read will timeout */ public function getReadWriteTimeout() { @@ -232,14 +242,18 @@ class Client /** * Helper to avoid duplicating milliseconds to secs/usecs in a few places * - * @param Integer millisecond timeout - * @return Boolean + * @param int millisecond timeout + * @return bool */ private function set_ms_timeout($timeoutMs) { if (!$this->_sock) { return false; } - return stream_set_timeout($this->_sock, floor($timeoutMs / 1000), ($timeoutMs % 1000) * 1000); + return stream_set_timeout( + $this->_sock, + floor($timeoutMs / 1000), + ($timeoutMs % 1000) * 1000 + ); } @@ -250,9 +264,21 @@ class Client { if (!$this->_sock) { if ($this->_persistentSocket) { - $this->_sock = pfsockopen($this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout/1000); + $this->_sock = pfsockopen( + $this->_host, + $this->_port, + $errno, + $errstr, + $this->_connectTimeout/1000 + ); } else { - $this->_sock = fsockopen($this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout/1000); + $this->_sock = fsockopen( + $this->_host, + $this->_port, + $errno, + $errstr, + $this->_connectTimeout/1000 + ); } if (!$this->_sock) { @@ -268,9 +294,10 @@ class Client /** * Build a FastCGI packet * - * @param Integer $type Type of the packet - * @param String $content Content of the packet - * @param Integer $requestId RequestId + * @param int $type Type of the packet + * @param string $content Content of the packet + * @param int $requestId RequestId + * @return string */ private function buildPacket($type, $content, $requestId = 1) { @@ -289,9 +316,9 @@ class Client /** * Build an FastCGI Name value pair * - * @param String $name Name - * @param String $value Value - * @return String FastCGI Name value pair + * @param string $name Name + * @param string $value Value + * @return string FastCGI Name value pair */ private function buildNvpair($name, $value) { @@ -302,14 +329,16 @@ class Client $nvpair = chr($nlen); } else { /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */ - $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF); + $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) + . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF); } if ($vlen < 128) { /* valueLengthB0 */ $nvpair .= chr($vlen); } else { /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */ - $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF); + $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) + . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF); } /* nameData & valueData */ return $nvpair . $name . $value; @@ -318,7 +347,7 @@ class Client /** * Read a set of FastCGI Name value pairs * - * @param String $data Data containing the set of FastCGI NVPair + * @param string $data Data containing the set of FastCGI NVPair * @return array of NVPair */ private function readNvpair($data, $length = null) @@ -357,7 +386,7 @@ class Client /** * Decode a FastCGI Packet * - * @param String $data String containing all the packet + * @param string $data string containing all the packet * @return array */ private function decodePacketHeader($data) @@ -403,6 +432,7 @@ class Client * * @param array $requestedInfo information to retrieve * @return array + * @throws \Exception */ public function getValues(array $requestedInfo) { @@ -423,11 +453,14 @@ class Client } /** - * Execute a request to the FastCGI application + * Execute a request to the FastCGI application and return response body * * @param array $params Array of parameters - * @param String $stdin Content - * @return String + * @param string $stdin Content + * @return string + * @throws ForbiddenException + * @throws TimedOutException + * @throws \Exception */ public function request(array $params, $stdin) { @@ -435,20 +468,38 @@ class Client return $this->wait_for_response($id); } + /** + * Execute a request to the FastCGI application and return request data + * + * @param array $params Array of parameters + * @param string $stdin Content + * @return array + * @throws ForbiddenException + * @throws TimedOutException + * @throws \Exception + */ + public function request_data(array $params, $stdin) + { + $id = $this->async_request($params, $stdin); + return $this->wait_for_response_data($id); + } + /** * Execute a request to the FastCGI application asyncronously - * + * * This sends request to application and returns the assigned ID for that request. * * You should keep this id for later use with wait_for_response(). Ids are chosen randomly - * rather than seqentially to guard against false-positives when using persistent sockets. - * In that case it is possible that a delayed response to a request made by a previous script - * invocation comes back on this socket and is mistaken for response to request made with same ID - * during this request. + * rather than sequentially to guard against false-positives when using persistent sockets. + * In that case it is possible that a delayed response to a request made by a previous script + * invocation comes back on this socket and is mistaken for response to request made with same + * ID during this request. * * @param array $params Array of parameters - * @param String $stdin Content - * @return Integer + * @param string $stdin Content + * @return int + * @throws TimedOutException + * @throws \Exception */ public function async_request(array $params, $stdin) { @@ -460,10 +511,12 @@ class Client // Using persistent sockets implies you want them keept alive by server! $keepAlive = intval($this->_keepAlive || $this->_persistentSocket); - $request = $this->buildPacket(self::BEGIN_REQUEST - ,chr(0) . chr(self::RESPONDER) . chr($keepAlive) . str_repeat(chr(0), 5) - ,$id - ); + $request = $this->buildPacket( + self::BEGIN_REQUEST, + chr(0) . chr(self::RESPONDER) . chr($keepAlive) + . str_repeat(chr(0), 5), + $id + ); $paramsRequest = ''; foreach ($params as $key => $value) { @@ -494,21 +547,26 @@ class Client $this->_requests[$id] = array( 'state' => self::REQ_STATE_WRITTEN, - 'response' => null + 'response' => null, + 'err_response' => null, + 'out_response' => null, ); return $id; } /** - * Blocking call that waits for response to specific request - * - * @param Integer $requestId - * @param Integer $timeoutMs [optional] the number of milliseconds to wait. Defaults to the ReadWriteTimeout value set. - * @return string response body + * Blocking call that waits for response data of the specific request + * + * @param int $requestId + * @param int $timeoutMs [optional] the number of milliseconds to wait. + * @return array response data + * @throws ForbiddenException + * @throws TimedOutException + * @throws \Exception */ - public function wait_for_response($requestId, $timeoutMs = 0) { - + public function wait_for_response_data($requestId, $timeoutMs = 0) + { if (!isset($this->_requests[$requestId])) { throw new \Exception('Invalid request id given'); } @@ -537,12 +595,15 @@ class Client if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) { if ($resp['type'] == self::STDERR) { $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_ERR; + $this->_requests[$resp['requestId']]['err_response'] .= $resp['content']; + } else { + $this->_requests[$resp['requestId']]['out_response'] .= $resp['content']; } $this->_requests[$resp['requestId']]['response'] .= $resp['content']; } if ($resp['type'] == self::END_REQUEST) { $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_OK; - if ($resp['requestId'] == $requestId) { + if ($resp['requestId'] == $requestId) { break; } } @@ -586,7 +647,22 @@ class Client throw new \Exception('Role value not known [UNKNOWN_ROLE]'); break; case self::REQUEST_COMPLETE: - return $this->_requests[$requestId]['response']; + return $this->_requests[$requestId]; } } + + /** + * Blocking call that waits for response to specific request + * + * @param int $requestId + * @param int $timeoutMs [optional] the number of milliseconds to wait. + * @return string The response content. + * @throws ForbiddenException + * @throws TimedOutException + * @throws \Exception + */ + public function wait_for_response($requestId, $timeoutMs = 0) + { + return $this->wait_for_response_data($requestId, $timeoutMs)['response']; + } } diff --git a/sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt b/sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt new file mode 100644 index 00000000000..475bc130a42 --- /dev/null +++ b/sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt @@ -0,0 +1,48 @@ +--TEST-- +FPM: GHSA-7qg2-v9fj-4mwv - status xss +--SKIPIF-- + +--FILE-- +start(); +$tester->expectLogStartNotices(); +$responses = $tester + ->multiRequest([ + ['uri' => '/', 'query' => ''], + ['uri' => '/status', 'query' => 'full&html', 'delay' => 100000], + ]); +var_dump(strpos($responses[1]->getBody(), '