Commit 263743b8442 for php.net
commit 263743b8442e2adecc23643fe7fbc010bd770c38
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 23212414d36..700b958f7df 100644
--- a/NEWS
+++ b/NEWS
@@ -230,6 +230,8 @@ PHP NEWS
null bytes. (Weilin Du)
. ini_get_all() now includes the built-in default value in the details.
(sebastian)
+ . Fixed bug GH-22171 (Invalid auth header generation in
+ http(s) stream wrapper). (David Carlier)
- Streams:
. Added so_keepalive, tcp_keepidle, tcp_keepintvl and tcp_keepcnt stream
diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c
index f3172180bed..95a6b1930a1 100644
--- a/ext/standard/http_fopen_wrapper.c
+++ b/ext/standard/http_fopen_wrapper.c
@@ -755,14 +755,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