Commit f357164ca58 for php.net

commit f357164ca5883fed7dbecfdc05c1e0b309ec8418
Author: David Carlier <devnexen@gmail.com>
Date:   Thu May 28 18:44:11 2026 +0100

    ext/standard: http(s) wrapper corrupts the basic auth header on percent-encoded userinfo.

    php_url_decode() returns the shorter decoded length but ZSTR_LEN() is left
    untouched, so smart_str_append() carries the stale [decoded][NUL][undecoded
    tail] bytes into the base64 credentials.

    Fix #22171

    close GH-22172

diff --git a/NEWS b/NEWS
index d16352c2613..61bf6d5bcc3 100644
--- a/NEWS
+++ b/NEWS
@@ -237,6 +237,8 @@ PHP                                                                        NEWS
     (ndossche)
   . Fixed bug GH-20627 (Cannot identify some avif images with getimagesize).
     (y-guyon)
+  . Fixed bug GH-22171 (Invalid auth header generation in
+    http(s) stream wrapper). (David Carlier)

 - Sysvshm:
   . Fix memory leak in shm_get_var() when variable is corrupted. (ndossche)
diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c
index 8157f6a3cec..22700cf904f 100644
--- a/ext/standard/http_fopen_wrapper.c
+++ b/ext/standard/http_fopen_wrapper.c
@@ -758,14 +758,14 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper,
 		smart_str scratch = {0};

 		/* decode the strings first */
-		php_url_decode(ZSTR_VAL(resource->user), ZSTR_LEN(resource->user));
+		ZSTR_LEN(resource->user) = php_url_decode(ZSTR_VAL(resource->user), ZSTR_LEN(resource->user));

 		smart_str_append(&scratch, resource->user);
 		smart_str_appendc(&scratch, ':');

 		/* Note: password is optional! */
 		if (resource->password) {
-			php_url_decode(ZSTR_VAL(resource->password), ZSTR_LEN(resource->password));
+			ZSTR_LEN(resource->password) = php_url_decode(ZSTR_VAL(resource->password), ZSTR_LEN(resource->password));
 			smart_str_append(&scratch, resource->password);
 		}

diff --git a/ext/standard/tests/http/gh22171.phpt b/ext/standard/tests/http/gh22171.phpt
new file mode 100644
index 00000000000..0c1aa150f60
--- /dev/null
+++ b/ext/standard/tests/http/gh22171.phpt
@@ -0,0 +1,47 @@
+--TEST--
+GH-22171 (http(s) stream wrapper sends a corrupted Authorization header for percent-encoded userinfo)
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require 'server.inc';
+
+function probe(string $label, string $userinfo, string $expected): void
+{
+    $responses = [
+        "data://text/plain,HTTP/1.0 200 Ok\r\n\r\n",
+    ];
+
+    ['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+    $url = preg_replace('(^http://)', 'http://' . $userinfo . '@', $uri);
+    file_get_contents($url);
+
+    fseek($output, 0, SEEK_SET);
+    $output = stream_get_contents($output);
+
+    http_server_kill($pid);
+
+    if (preg_match('(^Authorization:\s*Basic\s+(\S+))mi', $output, $m)) {
+        $decoded = base64_decode($m[1]);
+    } else {
+        $decoded = '<no Authorization header>';
+    }
+
+    echo "=== {$label} ===", PHP_EOL;
+    echo "  decoded : ", addcslashes($decoded, "\0..\37"), PHP_EOL;
+    echo "  result  : ", ($decoded === $expected ? "OK" : "CORRUPT"), PHP_EOL;
+}
+
+probe('user only', '%76%6f%72%74%66%75', 'vortfu:');
+probe('user + password', '%76%6f%72%74%66%75:%70%61%73%73%77%6f%72%64', 'vortfu:password');
+?>
+--EXPECT--
+=== user only ===
+  decoded : vortfu:
+  result  : OK
+=== user + password ===
+  decoded : vortfu:password
+  result  : OK