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)