Alexandre Daubois Get in touch

Blog ·

CVE-2026-91766: PHP had the redirect credential leak curl fixed in 2018

PHP's http:// stream wrapper sent Authorization, Cookie and Proxy-Authorization to any host a redirect pointed at, the bug curl fixed in 2018.

CVE
CVE-2026-91766
Severity
medium
Affected
PHP < 8.2.34, 8.3.x < 8.3.35, 8.4.x < 8.4.26, 8.5.x < 8.5.11

If your PHP code calls file_get_contents() on an API with an Authorization: Bearer header and the API answers with a redirect to another host, PHP sends your token to that other host too.

The same goes for Cookie and Proxy-Authorization, and for a redirect that only changes the port or drops from HTTPS to plain HTTP, which puts the token on the network in cleartext. PHP releases before 8.2.34, 8.3.35, 8.4.26 and 8.5.11 behave this way, and curl fixed the same bug in January 2018.

Where the headers go on a redirect

In PHP, file_get_contents() and fopen() accept a URL, and an http:// or https:// URL goes to a C wrapper in ext/standard/http_fopen_wrapper.c.

Extra request headers come from a stream context, an options array you build with stream_context_create(), and follow_location is on by default. The advisory’s proof of concept looks like any API call you have written:

<?php
$ctx = stream_context_create(['http' => [
'header' => "Authorization: Bearer SECRET\r\nCookie: sid=abc",
'follow_location' => 1,
]]);
file_get_contents('https://example.com/api', false, $ctx);

If example.com answers 302 Found with a Location on another host, that host receives both headers. Before the fix, the C side of the redirect looked like this, trimmed:

/* runs for the first request and again for every redirect */
if (!header_init && !redirect_keep_method) {
/* strip POST headers on redirect */
strip_header(user_headers, t, "content-length:");
strip_header(user_headers, t, "content-type:");
} /* <- Authorization, Cookie, Proxy-Authorization go through untouched */

/* ... later, once the Location header is read ... */
php_url_free(resource); /* <- the current URL is thrown away */
if ((resource = php_url_parse(new_path)) == NULL) {
php_stream_wrapper_log_error(wrapper, options, "Invalid redirect URL! %s", new_path);
efree(new_path);
goto out;
}
int new_flags = HTTP_WRAPPER_REDIRECTED; /* <- nothing here says where the credentials belong */
if (response_code == 307 || response_code == 308) {
new_flags |= HTTP_WRAPPER_KEEP_METHOD;
}
stream = php_stream_url_wrap_http_ex(wrapper, new_path, mode, options, opened_path, context, --redirect_max, new_flags, response_header STREAMS_CC);

The redirect is a recursive call that receives the same context, so it reads the same header option and rebuilds the same header bag for the new URL. The wrapper removes Content-Length and Content-Type and nothing else, and only when a POST turns into a GET.

The wrapper also frees the current URL before it parses the next one, so by the time it builds the recursive call it has nothing left to compare the new host against.

The history, quickly

The wrapper treated the header option as part of the request it had been asked to make, so following a redirect meant making the same request somewhere else.

That behaviour dates from April 2003, when Sara Golemon added the header, method and content context options, which first shipped in PHP 5.0.0. The redirect code of that commit already recursed with the same context, so the two features have lived side by side from the start.

By 2011 the option carried credentials by design, since Dmitry Stogov added basic authentication for HTTPS through a proxy that year and it reads Proxy-Authorization from the same header option.

Two people did think about headers surviving a redirect, which is sort of the frustrating part. In 2005 Ilia Alshanetsky made a redirected POST turn into a GET, to “follow browsers and cURL” as his commit puts it, and stripped Content-Length and Content-Type on the way.

In 2013 Michael Wallner moved that stripping into strip_header() for bug #61548. Ilia and Michael both answered whether a header still makes sense once POST becomes GET, and neither change checked whether the request still went to the same server.

The test Michael added, bug61548.phpt, has asserted a small defect since that day. After the redirected GET the expected output shows two blank lines instead of one, because strip_header() removed the last header but left the line break in front of it, and the wrapper then appended its own. That was harmless on a GET with Connection: close, so the test recorded it, and I ran into it again during the fix.

curl made the same assumption and took two CVEs to get out of it. After Craig de Stigter reported CVE-2018-1000007, Daniel Stenberg made curl 7.58.0 keep a custom Authorization header on the host of the original URL unless the application opts out with CURLOPT_UNRESTRICTED_AUTH, and shipped it on 24 January 2018.

That check compared the host name only, so a different port or scheme on the same host still got Authorization and Cookie. Harry Sintonen reported that gap as CVE-2022-27776 and curl 7.83.0 closed it in April 2022, and the PHP wrapper had both bugs, the 2018 one and the 2022 one, until this release.

What Jakub and I changed

I wrote the fix with Jakub Zelenka. It parses the new URL before freeing the old one, compares scheme, host and port with the default port filled in, and carries a sticky flag into the recursive call:

-			php_url_free(resource);
- /* check for invalid redirection URLs */
- if ((resource = php_url_parse(new_path)) == NULL) {
+ php_url *new_resource = php_url_parse(new_path);
+ if (new_resource == NULL) {
...
+ int default_port = use_ssl ? 443 : 80;
+ bool same_origin = zend_string_equals_ci(resource->scheme, new_resource->scheme)
+ && zend_string_equals_ci(resource->host, new_resource->host)
+ && (resource->port ? resource->port : default_port)
+ == (new_resource->port ? new_resource->port : default_port);
+
+ php_url_free(resource);
+ resource = new_resource;
...
- int new_flags = HTTP_WRAPPER_REDIRECTED;
+ int new_flags = HTTP_WRAPPER_REDIRECTED | (flags & HTTP_WRAPPER_STRIP_AUTH);
+ if (!same_origin) {
+ new_flags |= HTTP_WRAPPER_STRIP_AUTH;
+ }

Once the flag is on, the wrapper strips authorization, cookie and, unless the request goes through a proxy, proxy-authorization from the user header bag of the next request.

The flag stays set for every later hop, so a redirect that comes back to the original host does not get the token back either, which matches libcurl with CURLOPT_UNRESTRICTED_AUTH disabled.

To check it, I pointed that same script at a local server that redirects to a second one on another port, which then redirects once to itself. PHP 8.5.10 sent Bearer SECRET on 2 of the 2 requests the second server received, and a build of the patched PHP-8.5 branch sent it on 0 of 2.

The fix strips three header names and no more, and the regression test asserts that a custom X-Custom header still reaches the other origin.

If your credentials travel in X-Api-Key or any other header of your own, set follow_location to 0 and follow the redirect yourself after checking where it points, because the wrapper will not.

The strip_header() mess

The messy part was strip_header(). The old version found the name with strstr() and removed only the first match, and when that match was the last line of the bag it cut the string there and left the preceding line break in place. That is the doubled blank line from bug61548.phpt.

Once the wrapper stripped a trailing Authorization line from a 307 or 308 POST, which keeps its body, the header block ended early, so the target received a truncated header block and then the wrong bytes as the body.

My first version for the 8.5 branch rewrote the loop to catch repeated headers but kept that truncation. The version Jakub and I wrote for 8.2 scans line by line and drops the preceding line break, and it also catches repeated headers, folded continuation lines and Authorization : ... with a space before the colon.

Jakub ported it onto the 8.5 release branch on 22 September, so my first version never reached a release. The 8.2 fix and his port both delete the doubled blank lines from the expected output of bug61548.phpt.

Other clients, other rules

Go and Python faced the same question of what counts as the same server, and each picked its own rule.

Go’s net/http forwards all the headers set on the initial request except Authorization, WWW-Authenticate and Cookie, which it drops when the redirect goes to a domain that is neither an exact match nor a subdomain of the initial one. A redirect from foo.com to sub.foo.com keeps them.

Python’s requests strips Authorization in should_strip_auth() when the hostname changes, but lets an http to https upgrade on the standard ports keep it, for backwards compatibility. curl and PHP now both require the same scheme, host and port.

If you wrap one of these clients, check which rule you inherit and whether it covers the header your secret lives in.

Timeline

  • Reported: 7 June 2026, by q2a3z, with Ilia Alshanetsky also credited as reporter
  • First fix on the 8.5 branch: 11 August 2026, 8f79b50, by me
  • Fixed: 14 September 2026, 5af9465, by me with Jakub Zelenka, merged up from PHP-8.2 between 18 and 22 September
  • Follow-up: 22 September 2026, 795440a, by Jakub Zelenka, porting the line-aware strip_header() to 8.5 and up
  • Published: 24 September 2026, CVSS 3.1 score 5.9
  • Patched versions: 8.2.34, 8.3.35, 8.4.26 and 8.5.11, released on 24 September 2026

curl needed four years and two advisories to go from comparing the host to comparing the origin, and the part I’d reuse is the small one: parse the next URL before freeing the current one, so the code still holds both when it decides what to send.

Sources