From e264b2ea077795cd9fc2adf65352187f809a08dd Mon Sep 17 00:00:00 2001 From: David Carlier Date: Thu, 28 May 2026 18:44:11 +0100 Subject: [PATCH 1/2] 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 --- ext/standard/http_fopen_wrapper.c | 4 +-- ext/standard/tests/http/gh22171.phpt | 47 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 ext/standard/tests/http/gh22171.phpt diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c index 8157f6a3cec0..22700cf904f1 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 000000000000..b256db67bc98 --- /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-- + +--INI-- +allow_url_fopen=1 +--FILE-- + $pid, 'uri' => $uri] = http_server($responses, $output); + + $url = preg_replace('#^http://#', 'http://' . $userinfo . '@', $uri); + file_get_contents($url); + + http_server_kill($pid); + + fseek($output, 0, SEEK_SET); + $output = stream_get_contents($output); + + if (preg_match('/^Authorization:\s*Basic\s+(\S+)/mi', $output, $m)) { + $decoded = base64_decode($m[1]); + } else { + $decoded = ''; + } + + 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 From 475d632476d3bb56ff8c40c001dfe095d83f80c7 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Fri, 29 May 2026 14:57:00 +0100 Subject: [PATCH 2/2] feedback --- ext/standard/tests/http/gh22171.phpt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ext/standard/tests/http/gh22171.phpt b/ext/standard/tests/http/gh22171.phpt index b256db67bc98..0c1aa150f60f 100644 --- a/ext/standard/tests/http/gh22171.phpt +++ b/ext/standard/tests/http/gh22171.phpt @@ -10,21 +10,21 @@ require 'server.inc'; function probe(string $label, string $userinfo, string $expected): void { - $responses = array( + $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); + $url = preg_replace('(^http://)', 'http://' . $userinfo . '@', $uri); file_get_contents($url); - http_server_kill($pid); - fseek($output, 0, SEEK_SET); $output = stream_get_contents($output); - if (preg_match('/^Authorization:\s*Basic\s+(\S+)/mi', $output, $m)) { + http_server_kill($pid); + + if (preg_match('(^Authorization:\s*Basic\s+(\S+))mi', $output, $m)) { $decoded = base64_decode($m[1]); } else { $decoded = '';