Commit 2ef7525be76 for php.net

commit 2ef7525be76a06e7748d51bd15e77caaecc013c2
Merge: 247ae249b1b 7d4f779ee99
Author: Jakub Zelenka <bukka@php.net>
Date:   Tue Sep 22 11:07:53 2026 +0200

    Merge branch 'PHP-8.4' into PHP-8.5

    * PHP-8.4:
      Skip bz2 GH-20807 test when less than 13 GiB of memory is available
      ext/soap: make GHSA-cj93-vc83-wgqv test lean and reliable
      Add NEWS entries
      ext/standard: Fix 1-char relative Location redirects after GH-23467
      [http] Fix out-of-bounds read on empty Location header
      Fix GHSA-ch8v-r6jh-4vvr: encode 0xFF in FILTER_SANITIZE_ENCODED
      Fix GHSA-9f67-6fw4-hpfp
      Fix GHSA-j3wh-g957-2m85: phar tar entry injection
      Fix GHSA-cj93-vc83-wgqv
      Fix GHSA-rgrp-mwpx-f6rm: unbounded recursion in ext/soap XML parsing and decoding
      Fix GHSA-fpwc-w8rq-cr92: strip credentials from user headers on cross-origin redirects
      Fix GHSA-r6x9-5r99-36j7: Various packet overreads in mysqlnd wireprotocol
      Fix GHSA-xr7j-rvgx-xq5p: OOB read in php_openssl_matches_wildcard_name()
      Fix GHSA-vvx9-73fr-5jjx: do not fall back to CN if the cert has a service ID
      Fix GHSA-62xp-839h-2637: FastCGI allowed_clients compared only 96 bits of IPv6 addresses
      Fix heap-buffer-overflow in convert stream filters with NUL in line-break-chars

    # Conflicts:
    #       NEWS
    #       ext/phar/tar.c
    #       ext/soap/php_http.c
    #       ext/soap/php_xml.c
    #       ext/standard/http_fopen_wrapper.c
    #       win32/ioutil.c

diff --cc NEWS
index eff356e5c49,a37bfa30e4b..2cb5e45fd70
--- a/NEWS
+++ b/NEWS
@@@ -119,7 -124,15 +119,9 @@@ PH

  - FPM:
    . Fixed bug GH-19320 (FPM UID and GID overflow). (Pratik Bhujel)
+   . Fixed GHSA-62xp-839h-2637 (IPv6 ACL bypass in FastCGI listen.allowed_clients
+     due to partial address comparison). (CVE-2026-91768) (Alexandre Daubois)

 -- Hash:
 -  . Fixed a buffer overflow in hash_pbkdf2() with a large output length.
 -    (Lazizbek Ergashev)
 -
  - Intl:
    . Fixed grapheme_strpos() and grapheme_strrpos() with an empty needle
      returning UTF-16 offsets instead of grapheme offsets. (Ilia Alshanetsky)
@@@ -153,9 -172,16 +159,16 @@@
    . Fixed a tracing JIT crash when compiling a side trace for a method of a
      class that could not be stored in the inheritance cache. (GH-21710)
      (Arnaud, iliaal)
 +  . Fixed a crash when the huge page SHM remap discarded mappings outside the
 +    reserved address range. (Piotr Hałas)

+ - OpenSSL:
+   . Fixed GHSA-vvx9-73fr-5jjx (TLS hostname verification falls back to CN after
+     SAN mismatch). (CVE-2026-91769) (Jakub Zelenka)
+   . Fixed GHSA-xr7j-rvgx-xq5p (Heap buffer overflow in
+     php_openssl_matches_wildcard_name() on crafted server certificate wildcard
+     CN). (CVE-2026-91767) (Jakub Zelenka)
+
  - PDO:
    . Fixed a leak when a persistent connection failed a liveness check
      with no other live PDO handle. (iliaal)
@@@ -173,12 -203,16 +186,14 @@@
      (Weilin Du)
    . Fixed bug GH-23477 (Memory leak on duplicate native Phar manifest entries).
      (Weilin Du)
+   . Fixed GHSA-j3wh-g957-2m85 (Integer overflow in phar_tar_number() allowing
+     TAR archive entry injection). (CVE-2026-6103) (Jakub Zelenka)

 -- SNMP:
 -  . Fixed bug GH-23453 (SNMP::setSecurity() frees a non-malloced address with a
 -    context engine ID longer than 32 bytes). (Lazizbek Ergashev)
 +- Readline:
 +  . Fixed the interactive shell not waiting for the pager process to exit.
 +    (Weilin Du)

  - SOAP:
 -  . Fixed bug GH-23447 (Segfault when a class passed to SoapServer::setClass()
 -    fails to initialize). (Lazizbek Ergashev)
    . Fixed WSDL cache corruption when a soap:header defines headerfaults.
      (Ilia Alshanetsky)
    . Fixed stack overflow when parsing a WSDL with self-referential schema
@@@ -187,8 -225,11 +206,9 @@@
  - Standard:
    . Fixed a segfault when a stream filter callback unsets StreamBucket::$data
      before re-attaching the bucket. (iliaal)
-   . Fixed an out-of-bounds read when following a redirect response with an
-     empty Location header. (iliaal)
+   . Fixed GHSA-7875-c8px-7q5f (Out-of-bounds read in the HTTP stream wrapper
+     when following a redirect with an empty Location header). (CVE-2026-93682)
+     (Ilia Alshanetsky, Jordi Kroon)
 -  . Fixed a memory leak in array_merge_recursive() when the recursive merge of
 -    an object converted to an array fails. (David Carlier)
    . Fixed read buffer compaction in php_stream_filter_flush(). (crystarm)
    . Fixed bug GH-22410 (Incorrect float behavior with large numbers).
      (arshidkv12)
@@@ -196,6 -237,10 +216,10 @@@
      argument number for $timeout). (lacatoire)
    . Fixed bug GH-23576 (Next index for array returned from array_keys() is
      wrong). (Lazizbek Ergashev)
+   . Fixed GHSA-88hq-2827-7pg6 (Out-of-bounds read in convert.* stream filters
+     when line-break-chars contains NUL). (CVE-2026-92842) (geeknik)
+   . Fixed GHSA-fpwc-w8rq-cr92 (Cross-origin credential leak in HTTP stream
 -    wrapper redirects). (CVE-2026-91766) (Alexandre Daubois, Jakub Zelenka)
++    wrapper redirects). (CVE-2026-91766) (Alexandre Daubois)

  - SimpleXML:
    . Fixed writing to a dimension of the object returned by attributes() not
@@@ -204,11 -249,19 +228,15 @@@
      SimpleXMLElement::addChild() not being accessible by property name when
      namespaces are involved. (Ilia Alshanetsky)

+ - Windows:
+   . Fixed GHSA-9f67-6fw4-hpfp (Reserved device names are not rejected before
+     file and stream I/O). (CVE-2026-17545) (Shivam Mathur, Jakub Zelenka)
+
  - Zip:
 +  . Fixed bug GH-17787 (ZipArchive stream stops reading early when the archive
 +    is freed while the stream is still open). (Eyüp Can Akman)
    . Fixed bug GH-23276 (ZipArchive subclass storing its own stream cannot be
      garbage collected). (Weilin Du, ndossche)
 -  . Fixed ZipArchive::extractTo() and ZipArchive::getFrom*() reporting success
 -    on corrupted entries. (David Carlier)
 -  . Fixed ZipArchive::getNameIndex() truncating the entry index to int.
 -    (David Carlier)
 -  . Fixed fstat() on a zip:// stream reporting success when the archive cannot
 -    be opened. (David Carlier)

  - SAPI:
    . Fixed fuzzer targets failing to build in isolation. (Mrmaxmeier)
diff --cc ext/phar/tar.c
index 743349d397a,1f0c3a47275..ea9b72b5998
--- a/ext/phar/tar.c
+++ b/ext/phar/tar.c
@@@ -271,8 -317,32 +317,32 @@@ zend_result phar_parse_tarfile(php_stre
  			}
  		}

- 		size = entry.uncompressed_filesize = entry.compressed_filesize =
- 			phar_tar_number(hdr->size, sizeof(hdr->size));
+ 		if (!phar_tar_size(hdr->size, sizeof(hdr->size), &size)) {
+ 			if (error) {
+ 				spprintf(error, 4096, "phar error: \"%s\" is a corrupted tar file (invalid entry size)", fname);
+ 			}
+ 			if (last_was_longlink) {
 -				pefree(entry.filename, myphar->is_persistent);
++				zend_string_free(entry.filename);
+ 			}
+ 			php_stream_close(fp);
+ 			phar_destroy_phar_data(myphar);
+ 			return FAILURE;
+ 		}
+ 		entry.uncompressed_filesize = entry.compressed_filesize = size;
+
+ 		/* GNU long link names are not supported, so refuse the record instead of
+ 		 * registering it as an entry and dropping the link target of the entry that follows */
+ 		if (hdr->typeflag == TAR_LONGLINK) {
+ 			if (error) {
+ 				spprintf(error, 4096, "phar error: \"%s\" is a tar file with an unsupported GNU long link entry", fname);
+ 			}
+ 			if (last_was_longlink) {
 -				pefree(entry.filename, myphar->is_persistent);
++				zend_string_free(entry.filename);
+ 			}
+ 			php_stream_close(fp);
+ 			phar_destroy_phar_data(myphar);
+ 			return FAILURE;
+ 		}

  		/* skip global/file headers (pax) */
  		if (!old && (hdr->typeflag == TAR_GLOBAL_HDR || hdr->typeflag == TAR_FILE_HDR)) {
@@@ -360,12 -430,13 +430,12 @@@ bail
  			goto bail;
  		}

- 		if (!last_was_longlink && hdr->typeflag == 'L') {
+ 		if (!last_was_longlink && hdr->typeflag == TAR_LONGNAME) {
  			last_was_longlink = 1;
  			/* support the ././@LongLink system for storing long filenames */
 -			entry.filename_len = entry.uncompressed_filesize;

  			/* Check for overflow - bug 61065 */
- 			if (entry.uncompressed_filesize == UINT_MAX || entry.uncompressed_filesize == 0) {
 -			if (entry.filename_len == 0 || entry.filename_len > totalsize) {
++			if (entry.uncompressed_filesize == 0 || entry.uncompressed_filesize > totalsize) {
  				if (error) {
  					spprintf(error, 4096, "phar error: \"%s\" is a corrupted tar file (invalid entry size)", fname);
  				}
diff --cc ext/soap/php_http.c
index c187d98eb5e,7a75523f0b9..a627d1adead
--- a/ext/soap/php_http.c
+++ b/ext/soap/php_http.c
@@@ -1148,44 -1143,37 +1148,44 @@@ try_again
  		char *loc;

  		if ((loc = get_http_header_value(ZSTR_VAL(http_headers), "Location:")) != NULL) {
 -			php_url *new_url  = php_url_parse(loc);
 +			const php_uri_parser *uri_parser = php_uri_get_parser(uri_parser_class);
 +			if (uri_parser == NULL) {
 +				efree(loc);
 +				zend_argument_value_error(6, "must be a valid URI parser name");
 +				return FALSE;
 +			}
 +
 +			php_uri *new_uri = php_uri_parse_to_struct(uri_parser, loc, strlen(loc), PHP_URI_COMPONENT_READ_MODE_RAW, true);
  			efree(loc);

 -			if (new_url != NULL) {
 +			if (new_uri != NULL) {
  				zend_string_release_ex(http_headers, 0);
  				zend_string_release_ex(http_body, 0);
 -				if (new_url->scheme == NULL && new_url->path != NULL) {
 -					new_url->scheme = phpurl->scheme ? zend_string_copy(phpurl->scheme) : NULL;
 -					if (new_url->host == NULL) {
 -						new_url->host = phpurl->host ? zend_string_copy(phpurl->host) : NULL;
 -						new_url->port = phpurl->port;
 +				if (new_uri->scheme == NULL && new_uri->path != NULL) {
 +					new_uri->scheme = uri->scheme ? zend_string_copy(uri->scheme) : NULL;
 +					if (new_uri->host == NULL) {
 +						new_uri->host = uri->host ? zend_string_copy(uri->host) : NULL;
 +						new_uri->port = uri->port;
  					}
 -					if (new_url->path && ZSTR_VAL(new_url->path)[0] != '/') {
 -						if (phpurl->path) {
 -							char *t = ZSTR_VAL(phpurl->path);
 +					if (new_uri->path && ZSTR_VAL(new_uri->path)[0] != '/') {
 +						if (uri->path) {
 +							char *t = ZSTR_VAL(uri->path);
  							char *p = strrchr(t, '/');
  							if (p) {
- 								zend_string *s = zend_string_alloc((p - t) + ZSTR_LEN(new_uri->path) + 2, 0);
 -								zend_string *s = zend_string_safe_alloc(1, p - t, ZSTR_LEN(new_url->path) + 2, 0);
++								zend_string *s = zend_string_safe_alloc(1, p - t, ZSTR_LEN(new_uri->path) + 2, 0);
  								strncpy(ZSTR_VAL(s), t, (p - t) + 1);
  								ZSTR_VAL(s)[(p - t) + 1] = 0;
 -								strcat(ZSTR_VAL(s), ZSTR_VAL(new_url->path));
 -								zend_string_release_ex(new_url->path, 0);
 -								new_url->path = s;
 +								strcat(ZSTR_VAL(s), ZSTR_VAL(new_uri->path));
 +								zend_string_release_ex(new_uri->path, 0);
 +								new_uri->path = s;
  							}
  						} else {
 -							zend_string *s = zend_string_alloc(ZSTR_LEN(new_url->path) + 2, 0);
 +							zend_string *s = zend_string_alloc(ZSTR_LEN(new_uri->path) + 2, 0);
  							ZSTR_VAL(s)[0] = '/';
  							ZSTR_VAL(s)[1] = 0;
 -							strcat(ZSTR_VAL(s), ZSTR_VAL(new_url->path));
 -							zend_string_release_ex(new_url->path, 0);
 -							new_url->path = s;
 +							strcat(ZSTR_VAL(s), ZSTR_VAL(new_uri->path));
 +							zend_string_release_ex(new_uri->path, 0);
 +							new_uri->path = s;
  						}
  					}
  				}
diff --cc ext/soap/php_soap.h
index aa3fb79e570,bc84ea27aa3..3fea968a06d
--- a/ext/soap/php_soap.h
+++ b/ext/soap/php_soap.h
@@@ -150,8 -149,13 +150,10 @@@ struct _soapService
  #define SOAP_SSL_METHOD_SSLv3   2
  #define SOAP_SSL_METHOD_SSLv23  3

+ #define SOAP_MAX_XML_DEPTH 2048
+ #define SOAP_MAX_DECODE_DEPTH (SOAP_MAX_XML_DEPTH * 2)

  ZEND_BEGIN_MODULE_GLOBALS(soap)
 -	HashTable  defEncNs;     /* mapping of default namespaces to prefixes */
 -	HashTable  defEnc;
 -	HashTable  defEncIndex;
  	HashTable *typemap;
  	int        cur_uniq_ns;
  	int        soap_version;
@@@ -171,10 -175,9 +173,11 @@@
  	HashTable  wsdl_cache;
  	int        cur_uniq_ref;
  	HashTable *ref_map;
+ 	unsigned int decode_depth;
  ZEND_END_MODULE_GLOBALS(soap)

 +extern zend_string *soap_lang_en;
 +
  #ifdef ZTS
  #include "TSRM.h"
  #endif
diff --cc ext/soap/php_xml.c
index b6b0c09b9d4,d353dc90a9d..2fd25d7478a
--- a/ext/soap/php_xml.c
+++ b/ext/soap/php_xml.c
@@@ -35,22 -35,20 +35,20 @@@ static bool is_blank(const xmlChar* str
  	return true;
  }

- /* removes all empty text, comments and other insignificant nodes */
 -/* removes all empty text, comments and other insignoficant nodes.
++/* removes all empty text, comments and other insignificant nodes.
+  * Iterative because recursion overflows the stack on a deep document. */
  static void cleanup_xml_node(xmlNodePtr node)
  {
- 	xmlNodePtr trav;
- 	xmlNodePtr del = NULL;
+ 	xmlNodePtr parent = node;
+ 	xmlNodePtr trav = node->children;

- 	trav = node->children;
  	while (trav != NULL) {
- 		if (del != NULL) {
- 			xmlUnlinkNode(del);
- 			xmlFreeNode(del);
- 			del = NULL;
- 		}
+ 		xmlNodePtr next = trav->next;
+
  		if (trav->type == XML_TEXT_NODE) {
  			if (is_blank(trav->content)) {
- 				del = trav;
+ 				xmlUnlinkNode(trav);
+ 				xmlFreeNode(trav);
  			}
  		} else if ((trav->type != XML_ELEMENT_NODE) &&
  		           (trav->type != XML_CDATA_SECTION_NODE)) {
@@@ -107,19 -155,21 +143,28 @@@ static xmlDocPtr soap_xmlParse_ex(xmlPa
  	} else {
  		ret = NULL;
  	}
 +	return ret;
 +}

 -/*
 -	xmlCleanupParser();
 -*/
 +xmlDocPtr soap_xmlParseFile(const char *filename)
 +{
 +	bool old_allow_url_fopen = PG(allow_url_fopen);
 +	PG(allow_url_fopen) = true;
 +	xmlParserCtxtPtr ctxt = xmlCreateFileParserCtxt(filename);
 +	PG(allow_url_fopen) = old_allow_url_fopen;
 +
 +	xmlDocPtr ret = soap_xmlParse_ex(ctxt);

  	if (ret) {
+ #if LIBXML_VERSION < 21300
+ 		if (is_nesting_too_deep((xmlNodePtr)ret)) {
+ 			/* php_sdl.c reports xmlGetLastError() as the reason, and libxml2 did
+ 			 * not fail here, so drop the error an earlier parse left behind. */
+ 			xmlResetLastError();
+ 			xmlFreeDoc(ret);
+ 			return NULL;
+ 		}
+ #endif
  		cleanup_xml_node((xmlNodePtr)ret);
  	}
  	return ret;
@@@ -127,9 -177,56 +172,16 @@@

  xmlDocPtr soap_xmlParseMemory(const void *buf, size_t buf_size)
  {
 -	xmlParserCtxtPtr ctxt = NULL;
 -	xmlDocPtr ret;
 -
 -
 -/*
 -	xmlInitParser();
 -*/
 -	ctxt = xmlCreateMemoryParserCtxt(buf, buf_size);
 -	if (ctxt) {
 -		bool old;
 -
 -		php_libxml_sanitize_parse_ctxt_options(ctxt);
 -		ctxt->sax->ignorableWhitespace = soap_ignorableWhitespace;
 -		ctxt->sax->comment = soap_Comment;
 -		ctxt->sax->warning = NULL;
 -		ctxt->sax->error = NULL;
 -		/*ctxt->sax->fatalError = NULL;*/
 -		/* TODO: In libxml2 2.14.0 change this to the new options API so we don't rely on deprecated APIs. */
 -		ZEND_DIAGNOSTIC_IGNORED_START("-Wdeprecated-declarations")
 -		ctxt->options |= XML_PARSE_HUGE;
 -		ZEND_DIAGNOSTIC_IGNORED_END
 -		old = php_libxml_disable_entity_loader(1);
 -		xmlParseDocument(ctxt);
 -		php_libxml_disable_entity_loader(old);
 -		if (ctxt->wellFormed) {
 -			ret = ctxt->myDoc;
 -			if (ret->URL == NULL && ctxt->directory != NULL) {
 -				ret->URL = xmlCharStrdup(ctxt->directory);
 -			}
 -		} else {
 -			ret = NULL;
 -			xmlFreeDoc(ctxt->myDoc);
 -			ctxt->myDoc = NULL;
 -		}
 -		xmlFreeParserCtxt(ctxt);
 -	} else {
 -		ret = NULL;
 -	}
 -
 -/*
 -	xmlCleanupParser();
 -*/
 +	xmlParserCtxtPtr ctxt = xmlCreateMemoryParserCtxt(buf, buf_size);
 +	xmlDocPtr ret = soap_xmlParse_ex(ctxt);

+ #if LIBXML_VERSION < 21300
+ 	if (ret && is_nesting_too_deep((xmlNodePtr)ret)) {
+ 		xmlFreeDoc(ret);
+ 		ret = NULL;
+ 	}
+ #endif
+
  /*
  	if (ret) {
  		cleanup_xml_node((xmlNodePtr)ret);
@@@ -256,8 -347,10 +308,10 @@@ xmlNodePtr get_node_with_attribute_ex(x
  	return NULL;
  }

 -xmlNodePtr get_node_with_attribute_recursive_ex(xmlNodePtr node, char *name, char *name_ns, char *attribute, char *value, char *attr_ns)
 +xmlNodePtr get_node_with_attribute_recursive_ex(xmlNodePtr node, const char *name, const char *name_ns, const char *attribute, const char *value, const char *attr_ns)
  {
+ 	unsigned int depth = 0;
+
  	while (node != NULL) {
  		if (node_is_equal_ex(node, name, name_ns)) {
  			xmlAttrPtr attr = get_attribute_ex(node->properties, attribute, attr_ns);
diff --cc ext/standard/http_fopen_wrapper.c
index 202178e41ff,2cc9e081ae7..b7a05e2942d
--- a/ext/standard/http_fopen_wrapper.c
+++ b/ext/standard/http_fopen_wrapper.c
@@@ -83,27 -81,70 +83,36 @@@
  #define HTTP_WRAPPER_HEADER_INIT    1
  #define HTTP_WRAPPER_REDIRECTED     2
  #define HTTP_WRAPPER_KEEP_METHOD    4
+ #define HTTP_WRAPPER_STRIP_AUTH     8

 -static char *next_header_line(char *line)
 -{
 -	while (*line != '\0' && *line != '\r' && *line != '\n') {
 -		line++;
 -	}
 -	if (*line == '\r') {
 -		line++;
 -	}
 -	if (*line == '\n') {
 -		line++;
 -	}
 -
 -	return line;
 -}
 -
 -/* Removes every line whose header name matches, along with the folded
 - * continuation lines carrying the rest of its value. Neither a repeated header
 - * nor an occurrence of the name inside another header's value may leave the real
 - * header behind, as that would defeat HTTP_WRAPPER_STRIP_AUTH. */
++/* Removes every line whose header name matches. Neither a repeated header nor an
++ * occurrence of the name inside another header's value may leave the real header
++ * behind, as that would defeat HTTP_WRAPPER_STRIP_AUTH. */
  static inline void strip_header(char *header_bag, char *lc_header_bag,
  		const char *lc_header_name)
  {
- 	char *lc_header_start = strstr(lc_header_bag, lc_header_name);
- 	if (lc_header_start
- 	&& (lc_header_start == lc_header_bag || *(lc_header_start-1) == '\n')
- 	) {
 -	size_t name_len = strlen(lc_header_name);
 -	char *lc_line = lc_header_bag;
++	char *lc_header_start = lc_header_bag;
+
 -	while (*lc_line != '\0') {
 -		if (strncmp(lc_line, lc_header_name, name_len) != 0) {
 -			lc_line = next_header_line(lc_line);
++	while ((lc_header_start = strstr(lc_header_start, lc_header_name))) {
++		if (lc_header_start != lc_header_bag && *(lc_header_start-1) != '\n') {
++			lc_header_start += strlen(lc_header_name);
+ 			continue;
+ 		}
+
 -		/* the whitespace RFC 7230 forbids before the colon is tolerated by some
 -		 * servers, so it must not hide the header from us either */
 -		const char *lc_colon = lc_line + name_len;
 -		while (*lc_colon == ' ' || *lc_colon == '\t') {
 -			lc_colon++;
 -		}
 +		char *header_start = header_bag + (lc_header_start - lc_header_bag);
 +		char *lc_eol = strchr(lc_header_start, '\n');

- 		if (lc_eol) {
- 			char *eol = header_start + (lc_eol - lc_header_start);
- 			size_t eollen = strlen(lc_eol);
-
- 			memmove(lc_header_start, lc_eol+1, eollen);
- 			memmove(header_start, eol+1, eollen);
- 		} else {
 -		if (*lc_colon != ':') {
 -			lc_line = next_header_line(lc_line);
 -			continue;
++		if (!lc_eol) {
 +			*lc_header_start = '\0';
 +			*header_start = '\0';
++			return;
  		}
+
 -		char *lc_next = next_header_line(lc_line);
 -		while (*lc_next == ' ' || *lc_next == '\t') {
 -			lc_next = next_header_line(lc_next);
 -		}
 -
 -		if (*lc_next == '\0') {
 -			/* drop the preceding line break too, or the one appended after the bag
 -			 * would close the header block early */
 -			while (lc_line > lc_header_bag
 -					&& (*(lc_line - 1) == '\r' || *(lc_line - 1) == '\n')) {
 -				--lc_line;
 -			}
 -		}
++		char *eol = header_start + (lc_eol - lc_header_start);
++		size_t eollen = strlen(lc_eol);
+
 -		size_t tail_len = strlen(lc_next) + 1;
 -		char *line = header_bag + (lc_line - lc_header_bag);
 -
 -		memmove(line, header_bag + (lc_next - lc_header_bag), tail_len);
 -		memmove(lc_line, lc_next, tail_len);
++		memmove(lc_header_start, lc_eol+1, eollen);
++		memmove(header_start, eol+1, eollen);
  	}
  }

@@@ -700,10 -740,25 +709,25 @@@ finish

  			if (!header_init && !redirect_keep_method) {
  				/* strip POST headers on redirect */
 -				strip_header(user_headers, t, "content-length");
 -				strip_header(user_headers, t, "content-type");
 +				strip_header(user_headers, t, "content-length:");
 +				strip_header(user_headers, t, "content-type:");
  			}

+ 			if (flags & HTTP_WRAPPER_STRIP_AUTH) {
 -				strip_header(user_headers, t, "authorization");
 -				strip_header(user_headers, t, "cookie");
++				strip_header(user_headers, t, "authorization:");
++				strip_header(user_headers, t, "cookie:");
+ 				if (!use_proxy) {
 -					strip_header(user_headers, t, "proxy-authorization");
++					strip_header(user_headers, t, "proxy-authorization:");
+ 				}
+ 			}
+
+ 			if (*user_headers == '\0') {
+ 				/* everything got stripped, keeping the empty bag would append a
+ 				 * stray CRLF and end the header block early */
+ 				efree(user_headers);
+ 				user_headers = NULL;
+ 			}
+
  			if (check_has_header(t, "user-agent:")) {
  				have_header |= HTTP_HEADER_USER_AGENT;
  			}
@@@ -1101,14 -1158,22 +1125,24 @@@
  				header_info.location = NULL;
  			}

- 			php_uri_struct_free(resource);
- 			/* check for invalid redirection URLs */
- 			if ((resource = php_uri_parse_to_struct(uri_parser, new_path, strlen(new_path), PHP_URI_COMPONENT_READ_MODE_RAW, true)) == NULL) {
 -			php_url *new_resource = php_url_parse(new_path);
++			php_uri *new_resource = php_uri_parse_to_struct(uri_parser, new_path, strlen(new_path), PHP_URI_COMPONENT_READ_MODE_RAW, true);
+ 			if (new_resource == NULL) {
  				php_stream_wrapper_log_error(wrapper, options, "Invalid redirect URL! %s", new_path);
  				efree(new_path);
  				goto out;
  			}

 -			int default_port = use_ssl ? 443 : 80;
 -			bool same_origin = zend_string_equals_ci(resource->scheme, new_resource->scheme)
++			zend_long default_port = use_ssl ? 443 : 80;
++			bool same_origin = resource->scheme && new_resource->scheme
++				&& zend_string_equals_ci(resource->scheme, new_resource->scheme)
++				&& resource->host && new_resource->host
+ 				&& zend_string_equals_ci(resource->host, new_resource->host)
+ 				&& (resource->port ? resource->port : default_port)
+ 					== (new_resource->port ? new_resource->port : default_port);
+
 -			php_url_free(resource);
++			php_uri_struct_free(resource);
+ 			resource = new_resource;
+
  #define CHECK_FOR_CNTRL_CHARS(val) { \
  	if (val) { \
  		unsigned char *s, *e; \
@@@ -1127,10 -1192,13 +1161,13 @@@
  			/* check for control characters in login, password & path */
  			if (strncasecmp(new_path, "http://", sizeof("http://") - 1) || strncasecmp(new_path, "https://", sizeof("https://") - 1)) {
  				CHECK_FOR_CNTRL_CHARS(resource->user);
 -				CHECK_FOR_CNTRL_CHARS(resource->pass);
 +				CHECK_FOR_CNTRL_CHARS(resource->password);
  				CHECK_FOR_CNTRL_CHARS(resource->path);
  			}
- 			int new_flags = HTTP_WRAPPER_REDIRECTED;
+ 			int new_flags = HTTP_WRAPPER_REDIRECTED | (flags & HTTP_WRAPPER_STRIP_AUTH);
+ 			if (!same_origin) {
+ 				new_flags |= HTTP_WRAPPER_STRIP_AUTH;
+ 			}
  			if (response_code == 307 || response_code == 308) {
  				/* RFC 7538 specifies that status code 308 does not allow changing the request method from POST to GET.
  				 * RFC 7231 does the same for status code 307.
diff --cc ext/standard/tests/http/ghsa-fpwc-w8rq-cr92.phpt
index 00000000000,5ccc88b3cc4..21a8b911a34
mode 000000,100644..100644
--- a/ext/standard/tests/http/ghsa-fpwc-w8rq-cr92.phpt
+++ b/ext/standard/tests/http/ghsa-fpwc-w8rq-cr92.phpt
@@@ -1,0 -1,159 +1,85 @@@
+ --TEST--
+ GHSA-fpwc-w8rq-cr92: strip credentials from user headers on cross-origin redirect
+ --INI--
+ allow_url_fopen=1
+ --SKIPIF--
+ <?php require 'server.inc'; http_server_skipif(); ?>
+ --FILE--
+ <?php
+ require 'server.inc';
+
+ function count_header(string $requests, string $name): int {
+     return preg_match_all('/^' . preg_quote($name, '/') . ':/mi', $requests);
+ }
+
+ function report(string $label, string $requests): void {
+     echo $label, "\n";
+     foreach (['Authorization', 'Cookie', 'Proxy-Authorization', 'X-Custom'] as $name) {
+         echo "  $name: ", count_header($requests, $name), "\n";
+     }
+ }
+
+ $ctx = stream_context_create(['http' => [
+     'header' => "Authorization: Bearer SECRET\r\n"
+         . "Cookie: sid=abc\r\n"
+         . "Proxy-Authorization: Basic Zm9vOmJhcg==\r\n"
+         . "X-Custom: keep-me",
+     'follow_location' => 1,
+ ]]);
+
+ /* server B listens on a different port than server A, so the hop from A to B is
+  * cross-origin; B then redirects to itself: credentials must stay withheld for
+  * that same-origin hop too */
+ $captureB = null;
+ ['pid' => $pidB, 'uri' => $uriB] = http_server([
+     "data://text/plain,HTTP/1.1 302 Found\r\nLocation: /second\r\nContent-Length: 0\r\n\r\n",
+     "data://text/plain,HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK",
+ ], $captureB);
+
+ $captureA = null;
+ ['pid' => $pidA, 'uri' => $uriA] = http_server([
+     "data://text/plain,HTTP/1.1 302 Found\r\nLocation: $uriB/first\r\nContent-Length: 0\r\n\r\n",
+ ], $captureA);
+
+ var_dump(file_get_contents($uriA . '/src', false, $ctx));
+
+ http_server_kill($pidA);
+ http_server_kill($pidB);
+
+ rewind($captureA);
+ rewind($captureB);
+ report('--- origin A (1 request) ---', stream_get_contents($captureA));
+ report('--- origin B (2 requests) ---', stream_get_contents($captureB));
+
+ /* same origin throughout: credentials must be sent on both hops */
+ $captureC = null;
+ ['pid' => $pidC, 'uri' => $uriC] = http_server([
+     "data://text/plain,HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n",
+     "data://text/plain,HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK",
+ ], $captureC);
+
+ var_dump(file_get_contents($uriC . '/first', false, $ctx));
+
+ http_server_kill($pidC);
+
+ rewind($captureC);
+ report('--- origin C (2 requests) ---', stream_get_contents($captureC));
 -
 -/* a stripped header that was last in the bag must not leave a trailing line break
 - * behind, or the request body would be pushed out of the request */
 -$ctx = stream_context_create(['http' => [
 -    'method' => 'POST',
 -    'content' => 'hello=world',
 -    'header' => "X-Custom: keep-me\r\nAuthorization: Bearer SECRET",
 -    'follow_location' => 1,
 -]]);
 -
 -$captureH = null;
 -['pid' => $pidH, 'uri' => $uriH] = http_server([
 -    "data://text/plain,HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK",
 -], $captureH);
 -
 -$captureG = null;
 -['pid' => $pidG, 'uri' => $uriG] = http_server([
 -    "data://text/plain,HTTP/1.1 307 Temporary Redirect\r\nLocation: $uriH/second\r\nContent-Length: 0\r\n\r\n",
 -], $captureG);
 -
 -var_dump(@file_get_contents($uriG . '/first', false, $ctx));
 -
 -http_server_kill($pidG);
 -http_server_kill($pidH);
 -
 -rewind($captureH);
 -echo "--- credential header last in the bag (307) ---\n";
 -echo preg_replace('/^Host:.*$/m', 'Host: ...', stream_get_contents($captureH));
 -
 -echo "--- malformed header bags ---\n";
 -foreach ([
 -    'folded value      ' => "Authorization:\r\n Bearer SECRET\r\nX-Custom: keep-me",
 -    'folded value (tab)' => "Authorization:\r\n\tBearer SECRET\r\nX-Custom: keep-me",
 -    'lone CR           ' => "X-Custom: keep-me\rAuthorization: Bearer SECRET",
 -    'space before colon' => "Authorization : Bearer SECRET\r\nX-Custom: keep-me",
 -    'tab before colon  ' => "Authorization\t: Bearer SECRET\r\nX-Custom: keep-me",
 -] as $label => $header) {
 -    $ctx = stream_context_create(['http' => ['header' => $header, 'follow_location' => 1]]);
 -
 -    $captureF = null;
 -    ['pid' => $pidF, 'uri' => $uriF] = http_server([
 -        "data://text/plain,HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK",
 -    ], $captureF);
 -
 -    $captureE = null;
 -    ['pid' => $pidE, 'uri' => $uriE] = http_server([
 -        "data://text/plain,HTTP/1.1 302 Found\r\nLocation: $uriF/second\r\nContent-Length: 0\r\n\r\n",
 -    ], $captureE);
 -
 -    file_get_contents($uriE . '/first', false, $ctx);
 -
 -    http_server_kill($pidE);
 -    http_server_kill($pidF);
 -
 -    rewind($captureF);
 -    $request = stream_get_contents($captureF);
 -    printf("  %s  SECRET leaked: %d, X-Custom kept: %d\n", $label,
 -        str_contains($request, 'SECRET'), str_contains($request, 'X-Custom'));
 -}
+ ?>
+ --EXPECT--
+ string(2) "OK"
+ --- origin A (1 request) ---
+   Authorization: 1
+   Cookie: 1
+   Proxy-Authorization: 1
+   X-Custom: 1
+ --- origin B (2 requests) ---
+   Authorization: 0
+   Cookie: 0
+   Proxy-Authorization: 0
+   X-Custom: 2
+ string(2) "OK"
+ --- origin C (2 requests) ---
+   Authorization: 2
+   Cookie: 2
+   Proxy-Authorization: 2
+   X-Custom: 2
 -string(2) "OK"
 ---- credential header last in the bag (307) ---
 -POST /second HTTP/1.1
 -Host: ...
 -Connection: close
 -Content-Length: 11
 -X-Custom: keep-me
 -Content-Type: application/x-www-form-urlencoded
 -
 -hello=world--- malformed header bags ---
 -  folded value        SECRET leaked: 0, X-Custom kept: 1
 -  folded value (tab)  SECRET leaked: 0, X-Custom kept: 1
 -  lone CR             SECRET leaked: 0, X-Custom kept: 1
 -  space before colon  SECRET leaked: 0, X-Custom kept: 1
 -  tab before colon    SECRET leaked: 0, X-Custom kept: 1
diff --cc win32/ioutil.c
index c9464c94744,40e0ce5a1f4..6b2b2856301
--- a/win32/ioutil.c
+++ b/win32/ioutil.c
@@@ -67,6 -67,163 +67,175 @@@
  #include <winnls.h>
  */

 -typedef HRESULT (__stdcall *MyPathCchCanonicalizeEx)(wchar_t *pszPathOut, size_t cchPathOut, const wchar_t *pszPathIn, unsigned long dwFlags);
++typedef ULONG (WINAPI *php_win32_ioutil_rtl_is_dos_device_name_u_t)(PCWSTR);
+
 -static MyPathCchCanonicalizeEx canonicalize_path_w = NULL;
++/* Resolved on first use as there is no ioutil init hook on this branch. */
++static php_win32_ioutil_rtl_is_dos_device_name_u_t php_win32_ioutil_get_rtl_is_dos_device_name_u(void)
++{/*{{{*/
++	static php_win32_ioutil_rtl_is_dos_device_name_u_t fn = NULL;
++	static BOOL resolved = FALSE;
+
 -typedef ULONG (WINAPI *php_win32_ioutil_rtl_is_dos_device_name_u_t)(PCWSTR);
++	if (!resolved) {
++		HMODULE hMod = GetModuleHandleW(L"ntdll.dll");
++		if (hMod) {
++			fn = (php_win32_ioutil_rtl_is_dos_device_name_u_t)GetProcAddress(hMod, "RtlIsDosDeviceName_U");
++		}
++		resolved = TRUE;
++	}
+
 -static php_win32_ioutil_rtl_is_dos_device_name_u_t rtl_is_dos_device_name_u = NULL;
++	return fn;
++}/*}}}*/
+
+ static BOOL php_win32_ioutil_is_reserved_name_w(const wchar_t *name, size_t len)
+ {/*{{{*/
+ 	if (len == 3) {
+ 		return _wcsnicmp(name, L"CON", 3) == 0
+ 			|| _wcsnicmp(name, L"PRN", 3) == 0
+ 			|| _wcsnicmp(name, L"AUX", 3) == 0
+ 			|| _wcsnicmp(name, L"NUL", 3) == 0;
+ 	}
+
+ 	if (len == 4 && (_wcsnicmp(name, L"COM", 3) == 0 || _wcsnicmp(name, L"LPT", 3) == 0)) {
+ 		return (name[3] >= L'1' && name[3] <= L'9')
+ 			|| name[3] == L'\u00B2'
+ 			|| name[3] == L'\u00B3'
+ 			|| name[3] == L'\u00B9';
+ 	}
+
+ 	return (len == 6 && _wcsnicmp(name, L"CONIN$", 6) == 0)
+ 		|| (len == 7 && _wcsnicmp(name, L"CONOUT$", 7) == 0);
+ }/*}}}*/
+
+ /* Also catches variants like NUL.txt, NUL:stream or "NUL ", as far as the OS treats them as devices. */
+ static BOOL php_win32_ioutil_is_reserved_component_w(const wchar_t *name, size_t len)
+ {/*{{{*/
+ 	size_t base_len = len;
+ 	wchar_t *tmp;
+ 	BOOL ret;
+ 	ALLOCA_FLAG(use_heap)
+
+ 	if (php_win32_ioutil_is_reserved_name_w(name, len)) {
+ 		return TRUE;
+ 	}
+
+ 	for (size_t i = 0; i < len; i++) {
+ 		if (name[i] == L'.' || name[i] == L':') {
+ 			base_len = i;
+ 			break;
+ 		}
+ 	}
+ 	while (base_len > 0 && name[base_len - 1] == L' ') {
+ 		base_len--;
+ 	}
+
+ 	if (base_len == len || !php_win32_ioutil_is_reserved_name_w(name, base_len)) {
+ 		return FALSE;
+ 	}
+
++	php_win32_ioutil_rtl_is_dos_device_name_u_t rtl_is_dos_device_name_u = php_win32_ioutil_get_rtl_is_dos_device_name_u();
+ 	if (!rtl_is_dos_device_name_u) {
+ 		return TRUE;
+ 	}
+
+ 	tmp = do_alloca((len + 1) * sizeof(wchar_t), use_heap);
+ 	memcpy(tmp, name, len * sizeof(wchar_t));
+ 	tmp[len] = L'\0';
+ 	ret = rtl_is_dos_device_name_u(tmp) > 0;
+ 	free_alloca(tmp, use_heap);
+
+ 	return ret;
+ }/*}}}*/
+
+ PW32IO php_win32_ioutil_path_kind php_win32_ioutil_path_kind_w(const wchar_t *path, size_t path_len)
+ {/*{{{*/
+ 	size_t i = 0;
+
+ 	while (i < path_len && !PHP_WIN32_IOUTIL_IS_SLASHW(path[i])) {
+ 		i++;
+ 	}
+
+ 	if (i == path_len && !(path_len > 2 && PHP_WIN32_IOUTIL_IS_LETTERW(path[0]) && path[1] == L':')) {
+ 		if (path_len == 0) {
+ 			return PHP_WIN32_IOUTIL_PATH_OK;
+ 		}
+ 		/* Bare device names like NUL or NUL: are kept working for BC. */
+ 		if (php_win32_ioutil_is_reserved_name_w(path, path_len - (path[path_len - 1] == L':'))) {
+ 			return PHP_WIN32_IOUTIL_PATH_DEVICE;
+ 		}
+ 		return php_win32_ioutil_is_reserved_component_w(path, path_len)
+ 			? PHP_WIN32_IOUTIL_PATH_RESERVED : PHP_WIN32_IOUTIL_PATH_OK;
+ 	}
+
+ 	/* Windows does not map device names within DOS device paths (\\.\, \\?\ and \??\). */
+ 	if (path_len >= 4 && PHP_WIN32_IOUTIL_IS_SLASHW(path[3])
+ 		&& ((PHP_WIN32_IOUTIL_IS_SLASHW(path[0]) && PHP_WIN32_IOUTIL_IS_SLASHW(path[1]) && (path[2] == L'.' || path[2] == L'?'))
+ 			|| (path[0] == L'\\' && path[1] == L'?' && path[2] == L'?'))) {
+ 		return PHP_WIN32_IOUTIL_PATH_OK;
+ 	}
+
+ 	i = 0;
+ 	if (path_len >= 2 && PHP_WIN32_IOUTIL_IS_SLASHW(path[0]) && PHP_WIN32_IOUTIL_IS_SLASHW(path[1])) {
+ 		/* UNC, skip server and share. */
+ 		for (int n = 0; n < 2; n++) {
+ 			while (i < path_len && PHP_WIN32_IOUTIL_IS_SLASHW(path[i])) {
+ 				i++;
+ 			}
+ 			while (i < path_len && !PHP_WIN32_IOUTIL_IS_SLASHW(path[i])) {
+ 				i++;
+ 			}
+ 		}
+ 	} else if (path_len >= 2 && PHP_WIN32_IOUTIL_IS_LETTERW(path[0]) && path[1] == L':') {
+ 		i = 2;
+ 	}
+
+ 	while (i < path_len) {
+ 		size_t start;
+
+ 		while (i < path_len && PHP_WIN32_IOUTIL_IS_SLASHW(path[i])) {
+ 			i++;
+ 		}
+ 		start = i;
+ 		while (i < path_len && !PHP_WIN32_IOUTIL_IS_SLASHW(path[i])) {
+ 			i++;
+ 		}
+ 		if (i > start && php_win32_ioutil_is_reserved_component_w(path + start, i - start)) {
+ 			return PHP_WIN32_IOUTIL_PATH_RESERVED;
+ 		}
+ 	}
+
+ 	return PHP_WIN32_IOUTIL_PATH_OK;
+ }/*}}}*/
+
+ PW32IO php_win32_ioutil_path_kind php_win32_ioutil_path_kind_a(const char *path, size_t path_len)
+ {/*{{{*/
+ 	wchar_t *pathw;
+ 	size_t i, pathw_len;
+ 	php_win32_ioutil_path_kind ret;
+ 	ALLOCA_FLAG(use_heap)
+
+ 	for (i = 0; i < path_len && !(path[i] & 0x80); i++);
+
+ 	if (i < path_len) {
+ 		pathw = php_win32_cp_conv_any_to_w(path, path_len, &pathw_len);
+ 		if (!pathw) {
+ 			return PHP_WIN32_IOUTIL_PATH_OK;
+ 		}
+ 		ret = php_win32_ioutil_path_kind_w(pathw, pathw_len);
+ 		free(pathw);
+ 		return ret;
+ 	}
+
+ 	/* ASCII only, widen on the stack instead of a full conversion. */
+ 	pathw = do_alloca((path_len + 1) * sizeof(wchar_t), use_heap);
+ 	for (i = 0; i < path_len; i++) {
+ 		pathw[i] = (wchar_t) path[i];
+ 	}
+ 	ret = php_win32_ioutil_path_kind_w(pathw, path_len);
+ 	free_alloca(pathw, use_heap);
+
+ 	return ret;
+ }/*}}}*/
+
  PW32IO BOOL php_win32_ioutil_posix_to_open_opts(int flags, mode_t mode, php_ioutil_open_opts *opts)
  {/*{{{*/
  	int current_umask;