Commit 51d26b34679 for php.net
commit 51d26b3467951b17b46e5526052dfbdca8948be7
Author: Sjoerd Langkemper <sjoerd-github@linuxonly.nl>
Date: Sun Sep 6 14:42:50 2026 +0200
ext/standard: add tests for HTTP fopen wrapper (#23557)
diff --git a/ext/standard/tests/http/http_auto_decode.phpt b/ext/standard/tests/http/http_auto_decode.phpt
new file mode 100644
index 00000000000..c1fa2a64011
--- /dev/null
+++ b/ext/standard/tests/http/http_auto_decode.phpt
@@ -0,0 +1,61 @@
+--TEST--
+http.auto_decode stream context option controls chunked response decoding
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require 'server.inc';
+
+$chunked = "data://text/plain,HTTP/1.1 200 OK\r\n"
+ . "Transfer-Encoding: chunked\r\n\r\n"
+ . "2\r\nab\r\n2\r\ncd\r\n0\r\n\r\n";
+
+$responses = [$chunked, $chunked, $chunked];
+['pid' => $pid, 'uri' => $uri] = http_server($responses);
+
+function test_auto_decode($auto_decode) {
+ global $uri;
+ $ctx = null;
+ if ($auto_decode !== null) {
+ $ctx = stream_context_create(['http' => ['auto_decode' => $auto_decode]]);
+ }
+ $body = file_get_contents($uri, false, $ctx);
+
+ $has_te = false;
+ foreach (http_get_last_response_headers() as $h) {
+ if (stripos($h, 'Transfer-Encoding:') === 0) {
+ $has_te = true;
+ break;
+ }
+ }
+
+ return [addcslashes($body, "\r\n"), $has_te];
+}
+
+var_dump(test_auto_decode(null));
+var_dump(test_auto_decode(true));
+var_dump(test_auto_decode(false));
+
+http_server_kill($pid);
+?>
+--EXPECT--
+array(2) {
+ [0]=>
+ string(4) "abcd"
+ [1]=>
+ bool(false)
+}
+array(2) {
+ [0]=>
+ string(4) "abcd"
+ [1]=>
+ bool(false)
+}
+array(2) {
+ [0]=>
+ string(31) "2\r\nab\r\n2\r\ncd\r\n0\r\n\r\n"
+ [1]=>
+ bool(true)
+}
diff --git a/ext/standard/tests/http/http_location_realloc.phpt b/ext/standard/tests/http/http_location_realloc.phpt
new file mode 100644
index 00000000000..df4e1057c33
--- /dev/null
+++ b/ext/standard/tests/http/http_location_realloc.phpt
@@ -0,0 +1,35 @@
+--TEST--
+HTTP response with two Location headers (second longer) triggers erealloc
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require 'server.inc';
+
+$responses = array(
+ "data://text/plain,HTTP/1.1 301 Moved Permanently\r\nLocation: /short\r\nLocation: /a_much_longer_path_than_short\r\nContent-Length: 0\r\n\r\n",
+);
+
+['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+$ctx = stream_context_create(['http' => ['follow_location' => 0]]);
+$result = file_get_contents("$uri/", false, $ctx);
+var_dump($result);
+var_dump(http_get_last_response_headers());
+
+http_server_kill($pid);
+?>
+--EXPECT--
+string(0) ""
+array(4) {
+ [0]=>
+ string(30) "HTTP/1.1 301 Moved Permanently"
+ [1]=>
+ string(16) "Location: /short"
+ [2]=>
+ string(40) "Location: /a_much_longer_path_than_short"
+ [3]=>
+ string(17) "Content-Length: 0"
+}
diff --git a/ext/standard/tests/http/http_proxy_auth_removed.phpt b/ext/standard/tests/http/http_proxy_auth_removed.phpt
new file mode 100644
index 00000000000..5c541e29950
--- /dev/null
+++ b/ext/standard/tests/http/http_proxy_auth_removed.phpt
@@ -0,0 +1,72 @@
+--TEST--
+Proxy-Authorization header removed from request after CONNECT tunnel
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php require_once 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require_once 'server.inc';
+
+$server = http_server_init($output);
+
+if (is_resource($server)) {
+ $conn = stream_socket_accept($server);
+
+ /* Read CONNECT request */
+ $req = '';
+ while (!str_contains($req, "\r\n\r\n")) {
+ $req .= fread($conn, 1024);
+ }
+
+ echo "CONNECT contains Proxy-Authorization: ";
+ var_dump(stripos($req, 'Proxy-Authorization:') !== false);
+
+ fwrite($conn, "HTTP/1.1 200 Connection established\r\n\r\n");
+ fflush($conn);
+
+ stream_context_set_option($conn, 'ssl', 'local_cert', __DIR__ . '/../../../openssl/tests/sni_server.pem');
+ stream_socket_enable_crypto($conn, true, STREAM_CRYPTO_METHOD_TLS_SERVER) or die('fail TLS handshake');
+
+ /* Read tunneled request */
+ $req2 = '';
+ while (!str_contains($req2, "\r\n\r\n")) {
+ $req2 .= fread($conn, 1024);
+ }
+
+ /* Must be removed */
+ echo "Proxied request contains Proxy-Authorization: ";
+ var_dump(stripos($req2, 'Proxy-Authorization:') !== false);
+
+ fwrite($conn,
+ "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
+ );
+
+ exit;
+}
+
+$host = parse_url($server['uri'], PHP_URL_HOST);
+$port = parse_url($server['uri'], PHP_URL_PORT);
+
+$ctx = stream_context_create([
+ 'http' => [
+ 'proxy' => "tcp://$host:$port",
+ 'header' => [
+ "Proxy-Authorization: Basic Zm9vOmJhcg==",
+ ],
+ ],
+ 'ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ],
+]);
+
+file_get_contents("https://www.php.net/", false, $ctx);
+
+http_server_kill($server['pid']);
+?>
+--EXPECT--
+CONNECT contains Proxy-Authorization: bool(true)
+Proxied request contains Proxy-Authorization: bool(false)
diff --git a/ext/standard/tests/http/http_proxy_ssl_connect_01.phpt b/ext/standard/tests/http/http_proxy_ssl_connect_01.phpt
new file mode 100644
index 00000000000..0d647b79d02
--- /dev/null
+++ b/ext/standard/tests/http/http_proxy_ssl_connect_01.phpt
@@ -0,0 +1,48 @@
+--TEST--
+HTTP proxy SSL CONNECT with Proxy-Authorization header (string, multi-line)
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require 'server.inc';
+
+$responses = array(
+ "data://text/plain,HTTP/1.0 200 Connection established\r\n\r\n",
+ "data://text/plain,",
+);
+
+['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+$host = parse_url($uri, PHP_URL_HOST);
+$port = parse_url($uri, PHP_URL_PORT);
+
+$ctx = stream_context_create([
+ 'http' => [
+ 'proxy' => "tcp://$host:$port",
+ 'header' => "X-Custom: test\r\nProxy-Authorization: Basic dXNlcjpwYXNz",
+ ],
+ 'ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ],
+]);
+@$result = file_get_contents("https://www.php.net/test", false, $ctx);
+var_dump($result);
+
+http_server_kill($pid);
+
+rewind($output);
+$request = stream_get_contents($output);
+var_dump(str_contains($request, 'CONNECT www.php.net:443 HTTP/1.0'));
+var_dump(str_contains($request, 'Proxy-Authorization: Basic dXNlcjpwYXNz'));
+var_dump(str_contains($request, 'X-Custom: test'));
+?>
+--EXPECT--
+bool(false)
+bool(true)
+bool(true)
+bool(false)
diff --git a/ext/standard/tests/http/http_proxy_ssl_connect_02.phpt b/ext/standard/tests/http/http_proxy_ssl_connect_02.phpt
new file mode 100644
index 00000000000..bff063e2a0a
--- /dev/null
+++ b/ext/standard/tests/http/http_proxy_ssl_connect_02.phpt
@@ -0,0 +1,46 @@
+--TEST--
+HTTP proxy SSL CONNECT without Proxy-Authorization header (FAILURE path)
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require 'server.inc';
+
+$responses = array(
+ "data://text/plain,HTTP/1.0 200 Connection established\r\n\r\n",
+ "data://text/plain,",
+);
+
+['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+$host = parse_url($uri, PHP_URL_HOST);
+$port = parse_url($uri, PHP_URL_PORT);
+
+$ctx = stream_context_create([
+ 'http' => [
+ 'proxy' => "tcp://$host:$port",
+ 'header' => "X-Custom: test\r\nX-Other: value",
+ ],
+ 'ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ],
+]);
+@$result = file_get_contents("https://www.php.net/test", false, $ctx);
+var_dump($result);
+
+http_server_kill($pid);
+
+rewind($output);
+$request = stream_get_contents($output);
+var_dump(str_contains($request, 'CONNECT www.php.net:443 HTTP/1.0'));
+var_dump(str_contains($request, 'Proxy-Authorization'));
+?>
+--EXPECT--
+bool(false)
+bool(true)
+bool(false)
diff --git a/ext/standard/tests/http/http_proxy_ssl_connect_03.phpt b/ext/standard/tests/http/http_proxy_ssl_connect_03.phpt
new file mode 100644
index 00000000000..90a19798f6d
--- /dev/null
+++ b/ext/standard/tests/http/http_proxy_ssl_connect_03.phpt
@@ -0,0 +1,48 @@
+--TEST--
+HTTP proxy SSL CONNECT with Proxy-Authorization header (array)
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require 'server.inc';
+
+$responses = array(
+ "data://text/plain,HTTP/1.0 200 Connection established\r\n\r\n",
+ "data://text/plain,",
+);
+
+['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+$host = parse_url($uri, PHP_URL_HOST);
+$port = parse_url($uri, PHP_URL_PORT);
+
+$ctx = stream_context_create([
+ 'http' => [
+ 'proxy' => "tcp://$host:$port",
+ 'header' => ["X-Custom: test", "Proxy-Authorization: Basic abc123"],
+ ],
+ 'ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ],
+]);
+@$result = file_get_contents("https://www.php.net/test", false, $ctx);
+var_dump($result);
+
+http_server_kill($pid);
+
+rewind($output);
+$request = stream_get_contents($output);
+var_dump(str_contains($request, 'CONNECT www.php.net:443 HTTP/1.0'));
+var_dump(str_contains($request, 'Proxy-Authorization: Basic abc123'));
+var_dump(str_contains($request, 'X-Custom'));
+?>
+--EXPECT--
+bool(false)
+bool(true)
+bool(true)
+bool(false)
diff --git a/ext/standard/tests/http/http_redirect_removes_post_headers.phpt b/ext/standard/tests/http/http_redirect_removes_post_headers.phpt
new file mode 100644
index 00000000000..6e0927016ec
--- /dev/null
+++ b/ext/standard/tests/http/http_redirect_removes_post_headers.phpt
@@ -0,0 +1,74 @@
+--TEST--
+POST Content-Type and Content-Length headers removed on redirect except for 307/308
+--SKIPIF--
+<?php
+require_once 'server.inc';
+http_server_skipif();
+?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require_once 'server.inc';
+
+$status_codes = [301, 302, 303, 307, 308];
+
+$responses = [];
+foreach ($status_codes as $code) {
+ $responses[] = "data://text/plain,"
+ . "HTTP/1.1 $code Redirect\r\n"
+ . "Location: /$code-redirected\r\n"
+ . "Content-Length: 0\r\n"
+ . "\r\n";
+
+ $responses[] = "data://text/plain,"
+ . "HTTP/1.1 200 OK\r\n"
+ . "Content-Length: 0\r\n"
+ . "\r\n";
+}
+
+$server = http_server($responses, $output);
+
+$context = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'content' => 'test=data',
+ 'follow_location' => 1,
+ 'max_redirects' => 3,
+ 'header' =>
+ "Content-Type: application/x-www-form-urlencoded\r\n" .
+ "Content-Length: 9\r\n",
+ ],
+]);
+
+foreach ($status_codes as $code) {
+ file_get_contents($server['uri'], false, $context);
+}
+
+http_server_kill($server['pid']);
+
+rewind($output);
+$contents = stream_get_contents($output);
+
+foreach ($status_codes as $code) {
+ if (!preg_match("~(GET|POST) /$code-redirected .*?\r\n\r\n~s", $contents, $matches)) {
+ die("fail redirect request for $code not found\n");
+ }
+ echo "Redirect request for $code has Content-Type: ";
+ var_dump(stripos($matches[0], 'content-type') !== false);
+ echo "Redirect request for $code has Content-Length: ";
+ var_dump(stripos($matches[0], 'content-length') !== false);
+}
+
+?>
+--EXPECT--
+Redirect request for 301 has Content-Type: bool(false)
+Redirect request for 301 has Content-Length: bool(false)
+Redirect request for 302 has Content-Type: bool(false)
+Redirect request for 302 has Content-Length: bool(false)
+Redirect request for 303 has Content-Type: bool(false)
+Redirect request for 303 has Content-Length: bool(false)
+Redirect request for 307 has Content-Type: bool(true)
+Redirect request for 307 has Content-Length: bool(true)
+Redirect request for 308 has Content-Type: bool(true)
+Redirect request for 308 has Content-Length: bool(true)
diff --git a/ext/standard/tests/http/http_relative_redirect.phpt b/ext/standard/tests/http/http_relative_redirect.phpt
new file mode 100644
index 00000000000..9c3036da5a1
--- /dev/null
+++ b/ext/standard/tests/http/http_relative_redirect.phpt
@@ -0,0 +1,186 @@
+--TEST--
+http wrapper resolves a relative redirect Location against the request path
+--DESCRIPTION--
+This test tests the current behavior, which is clearly not the correct behavior in some cases.
+--SKIPIF--
+<?php require_once 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+require_once 'server.inc';
+
+$uri_parsers = [null, 'Uri\Rfc3986\Uri', 'Uri\WhatWg\Url'];
+$froms = ['/dir/page', '/a', '/other', '', '/../../../foo', '/space bar'];
+$tos = ['/dir/page', 'dir/page', 'a', 'other', '', '../../../foo', 'space bar'];
+
+foreach ($uri_parsers as $uri_parser) {
+ foreach ($froms as $from) {
+ foreach ($tos as $to) {
+ $responses[] = "data://text/plain,HTTP/1.0 302 Found\r\nLocation: $to\r\n\r\n";
+ $responses[] = "data://text/plain,HTTP/1.0 204 No Content\r\n\r\n";
+ }
+ }
+}
+
+['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+foreach ($uri_parsers as $uri_parser) {
+ echo "# URI parser: $uri_parser\n";
+ foreach ($froms as $from) {
+ foreach ($tos as $to) {
+ ftruncate($output, 0);
+
+ $ctx = stream_context_create(['http' => [
+ 'follow_location' => true,
+ 'uri_parser_class' => $uri_parser,
+ ]]);
+ $body = @file_get_contents($uri . $from, false, $ctx);
+ rewind($output);
+
+ if ($body === false) {
+ $result = "failed";
+ // Remove request from responses queue
+ file_get_contents($uri);
+ } else {
+ $requests = stream_get_contents($output);
+ preg_match_all('~GET (.*) HTTP/1.~', $requests, $matches);
+ $result = $matches[1][1];
+ }
+ echo "Redirect from '$from' to '$to': $result\n";
+ }
+ }
+}
+
+http_server_kill($pid);
+?>
+--EXPECT--
+# URI parser:
+Redirect from '/dir/page' to '/dir/page': /dir/page
+Redirect from '/dir/page' to 'dir/page': /dir//dir/page
+Redirect from '/dir/page' to 'a': /a
+Redirect from '/dir/page' to 'other': /dir//other
+Redirect from '/dir/page' to '': /
+Redirect from '/dir/page' to '../../../foo': /dir//../../../foo
+Redirect from '/dir/page' to 'space bar': /dir//space bar
+Redirect from '/a' to '/dir/page': /dir/page
+Redirect from '/a' to 'dir/page': /dir/page
+Redirect from '/a' to 'a': /a
+Redirect from '/a' to 'other': /other
+Redirect from '/a' to '': /
+Redirect from '/a' to '../../../foo': /../../../foo
+Redirect from '/a' to 'space bar': /space bar
+Redirect from '/other' to '/dir/page': /dir/page
+Redirect from '/other' to 'dir/page': /dir/page
+Redirect from '/other' to 'a': /a
+Redirect from '/other' to 'other': /other
+Redirect from '/other' to '': /
+Redirect from '/other' to '../../../foo': /../../../foo
+Redirect from '/other' to 'space bar': /space bar
+Redirect from '' to '/dir/page': /dir/page
+Redirect from '' to 'dir/page': /dir/page
+Redirect from '' to 'a': /a
+Redirect from '' to 'other': /other
+Redirect from '' to '': /
+Redirect from '' to '../../../foo': /../../../foo
+Redirect from '' to 'space bar': /space bar
+Redirect from '/../../../foo' to '/dir/page': /dir/page
+Redirect from '/../../../foo' to 'dir/page': /../../..//dir/page
+Redirect from '/../../../foo' to 'a': /a
+Redirect from '/../../../foo' to 'other': /../../..//other
+Redirect from '/../../../foo' to '': /
+Redirect from '/../../../foo' to '../../../foo': /../../..//../../../foo
+Redirect from '/../../../foo' to 'space bar': /../../..//space bar
+Redirect from '/space bar' to '/dir/page': /dir/page
+Redirect from '/space bar' to 'dir/page': /dir/page
+Redirect from '/space bar' to 'a': /a
+Redirect from '/space bar' to 'other': /other
+Redirect from '/space bar' to '': /
+Redirect from '/space bar' to '../../../foo': /../../../foo
+Redirect from '/space bar' to 'space bar': /space bar
+# URI parser: Uri\Rfc3986\Uri
+Redirect from '/dir/page' to '/dir/page': /dir/page
+Redirect from '/dir/page' to 'dir/page': /dir//dir/page
+Redirect from '/dir/page' to 'a': /a
+Redirect from '/dir/page' to 'other': /dir//other
+Redirect from '/dir/page' to '': /
+Redirect from '/dir/page' to '../../../foo': /dir//../../../foo
+Redirect from '/dir/page' to 'space bar': failed
+Redirect from '/a' to '/dir/page': /dir/page
+Redirect from '/a' to 'dir/page': /dir/page
+Redirect from '/a' to 'a': /a
+Redirect from '/a' to 'other': /other
+Redirect from '/a' to '': /
+Redirect from '/a' to '../../../foo': /../../../foo
+Redirect from '/a' to 'space bar': failed
+Redirect from '/other' to '/dir/page': /dir/page
+Redirect from '/other' to 'dir/page': /dir/page
+Redirect from '/other' to 'a': /a
+Redirect from '/other' to 'other': /other
+Redirect from '/other' to '': /
+Redirect from '/other' to '../../../foo': /../../../foo
+Redirect from '/other' to 'space bar': failed
+Redirect from '' to '/dir/page': /dir/page
+Redirect from '' to 'dir/page': /dir/page
+Redirect from '' to 'a': /a
+Redirect from '' to 'other': /other
+Redirect from '' to '': /
+Redirect from '' to '../../../foo': /../../../foo
+Redirect from '' to 'space bar': failed
+Redirect from '/../../../foo' to '/dir/page': /dir/page
+Redirect from '/../../../foo' to 'dir/page': /../../..//dir/page
+Redirect from '/../../../foo' to 'a': /a
+Redirect from '/../../../foo' to 'other': /../../..//other
+Redirect from '/../../../foo' to '': /
+Redirect from '/../../../foo' to '../../../foo': /../../..//../../../foo
+Redirect from '/../../../foo' to 'space bar': failed
+Redirect from '/space bar' to '/dir/page': failed
+Redirect from '/space bar' to 'dir/page': failed
+Redirect from '/space bar' to 'a': failed
+Redirect from '/space bar' to 'other': failed
+Redirect from '/space bar' to '': failed
+Redirect from '/space bar' to '../../../foo': failed
+Redirect from '/space bar' to 'space bar': failed
+# URI parser: Uri\WhatWg\Url
+Redirect from '/dir/page' to '/dir/page': /dir/page
+Redirect from '/dir/page' to 'dir/page': /dir//dir/page
+Redirect from '/dir/page' to 'a': /a
+Redirect from '/dir/page' to 'other': /dir//other
+Redirect from '/dir/page' to '': /
+Redirect from '/dir/page' to '../../../foo': /foo
+Redirect from '/dir/page' to 'space bar': /dir//space%20bar
+Redirect from '/a' to '/dir/page': /dir/page
+Redirect from '/a' to 'dir/page': /dir/page
+Redirect from '/a' to 'a': /a
+Redirect from '/a' to 'other': /other
+Redirect from '/a' to '': /
+Redirect from '/a' to '../../../foo': /foo
+Redirect from '/a' to 'space bar': /space%20bar
+Redirect from '/other' to '/dir/page': /dir/page
+Redirect from '/other' to 'dir/page': /dir/page
+Redirect from '/other' to 'a': /a
+Redirect from '/other' to 'other': /other
+Redirect from '/other' to '': /
+Redirect from '/other' to '../../../foo': /foo
+Redirect from '/other' to 'space bar': /space%20bar
+Redirect from '' to '/dir/page': /dir/page
+Redirect from '' to 'dir/page': /dir/page
+Redirect from '' to 'a': /a
+Redirect from '' to 'other': /other
+Redirect from '' to '': /
+Redirect from '' to '../../../foo': /foo
+Redirect from '' to 'space bar': /space%20bar
+Redirect from '/../../../foo' to '/dir/page': /dir/page
+Redirect from '/../../../foo' to 'dir/page': /dir/page
+Redirect from '/../../../foo' to 'a': /a
+Redirect from '/../../../foo' to 'other': /other
+Redirect from '/../../../foo' to '': /
+Redirect from '/../../../foo' to '../../../foo': /foo
+Redirect from '/../../../foo' to 'space bar': /space%20bar
+Redirect from '/space bar' to '/dir/page': /dir/page
+Redirect from '/space bar' to 'dir/page': /dir/page
+Redirect from '/space bar' to 'a': /a
+Redirect from '/space bar' to 'other': /other
+Redirect from '/space bar' to '': /
+Redirect from '/space bar' to '../../../foo': /foo
+Redirect from '/space bar' to 'space bar': /space%20bar
diff --git a/ext/standard/tests/http/http_sets_default_request_headers.phpt b/ext/standard/tests/http/http_sets_default_request_headers.phpt
new file mode 100644
index 00000000000..c86cea6cfae
--- /dev/null
+++ b/ext/standard/tests/http/http_sets_default_request_headers.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Test from and user-agent headers are set from ini and ctx
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+from=ini_from@php.net
+--FILE--
+<?php
+require 'server.inc';
+
+$responses = array(
+ "data://text/plain,HTTP/1.0 200 Ok\r\nContent-Length: 2\r\n\r\nok",
+);
+
+['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+$ctx = stream_context_create([
+ 'http' => [
+ 'user_agent' => 'SomeAgent',
+ 'content' => 'payload',
+ ],
+]);
+file_get_contents($uri, false, $ctx);
+
+http_server_kill($pid);
+
+rewind($output);
+$request = stream_get_contents($output);
+
+var_dump(str_contains($request, 'User-Agent: SomeAgent'));
+var_dump(str_contains($request, 'From: ini_from@php.net'));
+var_dump(str_contains($request, 'Content-Type: application/x-www-form-urlencoded'));
+var_dump(str_contains($request, 'Content-Length: 7'));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/http/http_user_header_precedence.phpt b/ext/standard/tests/http/http_user_header_precedence.phpt
new file mode 100644
index 00000000000..b5e562972bb
--- /dev/null
+++ b/ext/standard/tests/http/http_user_header_precedence.phpt
@@ -0,0 +1,54 @@
+--TEST--
+Header precedence when headers are supplied in multiple ways
+--SKIPIF--
+<?php require 'server.inc'; http_server_skipif(); ?>
+--INI--
+allow_url_fopen=1
+from=ini_from@example.com
+--FILE--
+<?php
+require 'server.inc';
+
+$responses = array(
+ "data://text/plain,HTTP/1.0 200 Ok\r\nContent-Length: 2\r\n\r\nok",
+);
+
+['pid' => $pid, 'uri' => $uri] = http_server($responses, $output);
+
+$ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "User-Agent: MyCustomUA\r\n"
+ . "From: me@example.com\r\n"
+ . "Content-Length: 5\r\n"
+ . "Content-Type: application/json\r\n"
+ . "X-Custom: test",
+ 'user_agent' => 'AutoGeneratedUA',
+ 'content' => '0123456789',
+ ],
+]);
+file_get_contents($uri, false, $ctx);
+
+http_server_kill($pid);
+
+rewind($output);
+$request = stream_get_contents($output);
+
+var_dump(str_contains($request, 'User-Agent: MyCustomUA'));
+var_dump(str_contains($request, 'AutoGeneratedUA'));
+var_dump(substr_count($request, 'User-Agent:'));
+var_dump(str_contains($request, 'From: me@example.com'));
+var_dump(str_contains($request, 'ini_from@example.com'));
+var_dump(substr_count($request, 'From:'));
+var_dump(str_contains($request, 'Content-Length: 5'));
+var_dump(substr_count($request, 'Content-Length:'));
+?>
+--EXPECT--
+bool(true)
+bool(false)
+int(1)
+bool(true)
+bool(false)
+int(1)
+bool(true)
+int(1)