CVE-2026-6276: stale custom cookie host causes cookie leak
What the report claims
A stale custom cookie host causes cookie leak by reusing stale cookiehost value across requests on the same easy handle.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-6276 stale custom cookie host causes cookie leak
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The code supports the reported flaw. A maintainer should review its impact and decide what to do next.
Why this finding?
- The code at lib/http.c:2065 sets data->state.aptr.cookiehost from a custom Host: header.
- The non-custom-host path (lib/http.c:2075-2089) does not clear data->state.aptr.cookiehost.
- Outgoing cookie selection (lib/http.c:2560-2561) and incoming Set-Cookie attribution (lib/http.c:3561-3562) both prefer data->state.aptr.cookiehost over the actual connection host.
- This allows cross-origin cookie leakage and injection across reused easy handles, a confidentiality and integrity breach.
- The issue is present in the checked revision (master on 2026-04-14) and matches the reported version curl 8.19.0.
How this analysis ran 6 claims · 9 citations verified
Checked against curl master on 2026-04-14 (bcb02fe5265f). Test evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Review a draft reply to the reporter
Edit and verify before sending.
Thank you for the detailed report and PoC. The code confirms the flaw: lib/http.c:2065 sets cookiehost from a custom Host: header, but the non-custom-host path at lines 2075-2089 does not clear it. Both outgoing cookie selection (line 2560) and Set-Cookie attribution (line 3561) then reuse the stale value, enabling cross-origin cookie leakage and injection. This is a valid security issue in curl 8.19.0 and current master. We will prepare a fix to clear cookiehost when switching away from a custom Host: header.
Inspect all claims and source code 6 claims · 3 code excerpts
Report claims
libcurl keeps a stale data->state.aptr.cookiehost after a request that uses a custom Host: header
Read the reasoning
The code at line 2065 assigns the extracted custom Host: hostname to aptr->cookiehost (data->state.aptr.cookiehost) when a custom Host: header is present and conditions are met.
On later requests without a custom Host: header, libcurl reuses that stale value for outgoing cookie selection
Read the reasoning
http_cookies() at line 2560-2561 uses data->state.aptr.cookiehost if set, otherwise falls back to the actual connection host. Since cookiehost is not cleared when switching away from a custom Host:, the stale value is reused for outgoing cookie selection.
The stale state is also used for incoming Set-Cookie attribution
Read the reasoning
http_header_s() at line 3561-3562 uses data->state.aptr.cookiehost for Set-Cookie attribution if set, causing incoming cookies to be stored under the stale custom host instead of the actual request host.
The stale state is set from the custom-Host: path and is not cleared in the non-custom-host path
Read the reasoning
The custom-Host: path (lines 2030-2065) sets aptr->cookiehost. The non-custom-host path (lines 2075-2089) only sets aptr->host (the outgoing Host header) but never clears or resets aptr->cookiehost, leaving the stale value.
A cookie seeded for victim.internal is leaked to attacker.test, and poisoned cookies are replayed back to victim.internal
Read the reasoning
The code path allows exactly the described attack: request 1 with custom Host: victim.internal sets cookiehost=victim.internal; request 2 to attacker.test (no custom Host:) sends victim.internal's cookies to attacker.test (line 2560) and attributes attacker.test's Set-Cookie to victim.internal (line 3561); request 3 to victim.internal replays the poisoned cookies.
The PoC uses one easy handle with an in-memory cookie jar, sends request 1 with Host: victim.internal, removes that custom header for request 2 to attacker.test, and then sends request 3 back to victim.internal
Read the reasoning
The reproduction steps match the code flow: first request with custom Host: triggers the if-branch at line 2032 setting cookiehost; second request without custom Host: takes the else-branch at line 2075 which doesn't clear cookiehost; third request again takes the else-branch but cookiehost remains stale from request 1.
What the report says about other projects
confirmed The cookiehost value must be cleared when switching away from a custom Host: header to avoid cross-origin cookie leakage.
“When an origin server receives a request with an absolute-form of request-target, the origin server MUST ignore the received Host header field (if any) and instead use the host information of the request-target.” source
Read the original report (excerpt)
Source code
| 2535 | if(announced_exp100) |
| 2536 | result = http_exp100_add_reader(data); |
| 2537 | |
| 2538 | out: |
| 2539 | return result; |
| 2540 | } |
| 2541 | |
| 2542 | #ifndef CURL_DISABLE_COOKIES |
| 2543 | |
| 2544 | static CURLcode http_cookies(struct Curl_easy *data, |
| 2545 | struct dynbuf *r) |
| 2546 | { |
| 2547 | CURLcode result = CURLE_OK; |
| 2548 | char *addcookies = NULL; |
| 2549 | bool linecap = FALSE; |
| 2550 | if(data->set.str[STRING_COOKIE] && |
| 2551 | !Curl_checkheaders(data, STRCONST("Cookie"))) |
| 2552 | addcookies = data->set.str[STRING_COOKIE]; |
| 2553 | |
| 2554 | if(data->cookies || addcookies) { |
| 2555 | struct Curl_llist list; |
| 2556 | int count = 0; |
| 2557 | |
| 2558 | if(data->cookies && data->state.cookie_engine) { |
| 2559 | bool okay; |
| 2560 | const char *host = data->state.aptr.cookiehost ? |
| C2: const char *host = data->state.aptr.cookiehost ? data->state.aptr.cookiehost : data->conn->host.name; | |
| C5: Outgoing cookie selection uses stale cookiehost | |
| 2561 | data->state.aptr.cookiehost : data->conn->host.name; |
| 2562 | Curl_share_lock(data, CURL_LOCK_DATA_COOKIE, CURL_LOCK_ACCESS_SINGLE); |
| 2563 | result = Curl_cookie_getlist(data, data->conn, &okay, host, &list); |
| 2564 | if(!result && okay) { |
| 2565 | struct Curl_llist_node *n; |
| 2566 | size_t clen = 8; /* hold the size of the generated Cookie: header */ |
| 2567 | |
| 2568 | /* loop through all cookies that matched */ |
| 2569 | for(n = Curl_llist_head(&list); n; n = Curl_node_next(n)) { |
| 2570 | struct Cookie *co = Curl_node_elem(n); |
| 2571 | if(co->value) { |
| 2572 | size_t add; |
| 2573 | if(!count) { |
| 2574 | result = curlx_dyn_addn(r, STRCONST("Cookie: ")); |
| 2575 | if(result) |
| 2576 | break; |
| 2577 | } |
| 2578 | add = strlen(co->name) + strlen(co->value) + 1; |
| 2579 | if(clen + add >= MAX_COOKIE_HEADER_LEN) { |
| 2580 | infof(data, "Restricted outgoing cookies due to header size, " |
| 2581 | "'%s' not sent", co->name); |
| 2582 | linecap = TRUE; |
| 2583 | break; |
| 2584 | } |
| 2585 | result = curlx_dyn_addf(r, "%s%s=%s", count ? "; " : "", |
| 2586 | co->name, co->value); |
| 2587 | if(result) |
| 2588 | break; |
| 3536 | } |
| 3537 | return CURLE_OK; |
| 3538 | } |
| 3539 | |
| 3540 | /* |
| 3541 | * http_header_s() parses a single response header starting with S. |
| 3542 | */ |
| 3543 | static CURLcode http_header_s(struct Curl_easy *data, |
| 3544 | const char *hd, size_t hdlen) |
| 3545 | { |
| 3546 | #if !defined(CURL_DISABLE_COOKIES) || !defined(CURL_DISABLE_HSTS) |
| 3547 | struct connectdata *conn = data->conn; |
| 3548 | const char *v; |
| 3549 | #else |
| 3550 | (void)data; |
| 3551 | (void)hd; |
| 3552 | (void)hdlen; |
| 3553 | #endif |
| 3554 | |
| 3555 | #ifndef CURL_DISABLE_COOKIES |
| 3556 | v = (data->cookies && data->state.cookie_engine) ? |
| 3557 | HD_VAL(hd, hdlen, "Set-Cookie:") : NULL; |
| 3558 | if(v) { |
| 3559 | /* If there is a custom-set Host: name, use it here, or else use |
| 3560 | * real peer hostname. */ |
| 3561 | const char *host = data->state.aptr.cookiehost ? |
| C3: const char *host = data->state.aptr.cookiehost ? data->state.aptr.cookiehost : conn->host.name; | |
| C5: Incoming Set-Cookie attribution uses stale cookiehost | |
| 3562 | data->state.aptr.cookiehost : conn->host.name; |
| 3563 | const bool secure_context = Curl_secure_context(conn, host); |
| 3564 | CURLcode result; |
| 3565 | Curl_share_lock(data, CURL_LOCK_DATA_COOKIE, CURL_LOCK_ACCESS_SINGLE); |
| 3566 | result = Curl_cookie_add(data, data->cookies, TRUE, FALSE, v, host, |
| 3567 | data->state.up.path, secure_context); |
| 3568 | Curl_share_unlock(data, CURL_LOCK_DATA_COOKIE); |
| 3569 | return result; |
| 3570 | } |
| 3571 | #endif |
| 3572 | #ifndef CURL_DISABLE_HSTS |
| 3573 | /* If enabled, the header is incoming and this is over HTTPS */ |
| 3574 | v = (data->hsts && |
| 3575 | (Curl_xfer_is_secure(data) || |
| 3576 | #ifdef DEBUGBUILD |
| 3577 | /* allow debug builds to circumvent the HTTPS restriction */ |
| 3578 | getenv("CURL_HSTS_HTTP") |
| 3579 | #else |
| 3580 | 0 |
| 3581 | #endif |
| 3582 | ) |
| 3583 | ) ? HD_VAL(hd, hdlen, "Strict-Transport-Security:") : NULL; |
| 3584 | if(v) { |
| 3585 | CURLcode check = |
| 3586 | Curl_hsts_parse(data->hsts, conn->host.name, v); |
| 3587 | if(check) { |
| 3588 | if(check == CURLE_OUT_OF_MEMORY) |
| 3589 | return check; |
| 3590 | infof(data, "Illegal STS header skipped"); |
| 3591 | } |
| 3592 | #ifdef DEBUGBUILD |
| 2005 | curlx_free(data->state.aptr.uagent); |
| 2006 | data->state.aptr.uagent = NULL; |
| 2007 | } |
| 2008 | return CURLE_OK; |
| 2009 | } |
| 2010 | |
| 2011 | static CURLcode http_set_aptr_host(struct Curl_easy *data) |
| 2012 | { |
| 2013 | struct connectdata *conn = data->conn; |
| 2014 | struct dynamically_allocated_data *aptr = &data->state.aptr; |
| 2015 | const char *ptr; |
| 2016 | |
| 2017 | if(!data->state.this_is_a_follow) { |
| 2018 | /* Free to avoid leaking memory on multiple requests */ |
| 2019 | curlx_free(data->state.first_host); |
| 2020 | |
| 2021 | data->state.first_host = curlx_strdup(conn->host.name); |
| 2022 | if(!data->state.first_host) |
| 2023 | return CURLE_OUT_OF_MEMORY; |
| 2024 | |
| 2025 | data->state.first_remote_port = conn->remote_port; |
| 2026 | data->state.first_remote_protocol = conn->scheme->protocol; |
| 2027 | } |
| 2028 | curlx_safefree(aptr->host); |
| 2029 | |
| 2030 | ptr = Curl_checkheaders(data, STRCONST("Host")); |
| C6: Custom Host: detection and cookiehost extraction | |
| 2031 | if(ptr && (!data->state.this_is_a_follow || |
| 2032 | curl_strequal(data->state.first_host, conn->host.name))) { |
| 2033 | #ifndef CURL_DISABLE_COOKIES |
| 2034 | /* If we have a given custom Host: header, we extract the hostname in |
| 2035 | order to possibly use it for cookie reasons later on. We only allow the |
| 2036 | custom Host: header if this is NOT a redirect, as setting Host: in the |
| 2037 | redirected request is being out on thin ice. Except if the hostname |
| 2038 | is the same as the first one! */ |
| 2039 | char *cookiehost; |
| 2040 | CURLcode result = copy_custom_value(ptr, &cookiehost); |
| 2041 | if(result) |
| 2042 | return result; |
| 2043 | if(!*cookiehost) |
| 2044 | /* ignore empty data */ |
| 2045 | curlx_free(cookiehost); |
| 2046 | else { |
| 2047 | /* If the host begins with '[', we start searching for the port after |
| 2048 | the bracket has been closed */ |
| 2049 | if(*cookiehost == '[') { |
| 2050 | char *closingbracket; |
| 2051 | /* since the 'cookiehost' is an allocated memory area that will be |
| 2052 | freed later we cannot increment the pointer */ |
| 2053 | memmove(cookiehost, cookiehost + 1, strlen(cookiehost) - 1); |
| 2054 | closingbracket = strchr(cookiehost, ']'); |
| 2055 | if(closingbracket) |
| 2056 | *closingbracket = 0; |
| 2057 | } |
| 2058 | else { |
| 2059 | int startsearch = 0; |
| 2060 | char *colon = strchr(cookiehost + startsearch, ':'); |
| 2061 | if(colon) |
| 2062 | *colon = 0; /* The host must not include an embedded port number */ |
| 2063 | } |
| 2064 | curlx_free(aptr->cookiehost); |
| 2065 | aptr->cookiehost = cookiehost; |
| C1: aptr->cookiehost = cookiehost; sets the cookiehost from custom Host: header | |
| 2066 | } |
| 2067 | #endif |
| 2068 | |
| 2069 | if(!curl_strequal("Host:", ptr)) { |
| 2070 | aptr->host = curl_maprintf("Host:%s\r\n", &ptr[5]); |
| 2071 | if(!aptr->host) |
| 2072 | return CURLE_OUT_OF_MEMORY; |
| 2073 | } |
| 2074 | } |
| 2075 | else { |
| C4: else branch (non-custom-host path) sets aptr->host but does not clear aptr->cookiehost | |
| C6: Non-custom-host path without cookiehost clearing | |
| 2076 | /* Use the hostname as present in the URL if it was IPv6. */ |
| 2077 | char *host = (data->state.up.hostname[0] == '[') ? |
| 2078 | data->state.up.hostname : conn->host.name; |
| 2079 | |
| 2080 | if(((conn->given->protocol & (CURLPROTO_HTTPS | CURLPROTO_WSS)) && |
| 2081 | (conn->remote_port == PORT_HTTPS)) || |
| 2082 | ((conn->given->protocol & (CURLPROTO_HTTP | CURLPROTO_WS)) && |
| 2083 | (conn->remote_port == PORT_HTTP))) |
| 2084 | /* if(HTTPS on port 443) OR (HTTP on port 80) then do not include |
| 2085 | the port number in the host string */ |
| 2086 | aptr->host = curl_maprintf("Host: %s\r\n", host); |
| 2087 | else |
| 2088 | aptr->host = curl_maprintf("Host: %s:%d\r\n", host, conn->remote_port); |
| 2089 | |
| C4: End of else branch - no clearing of cookiehost | |
| 2090 | if(!aptr->host) |
| 2091 | /* without Host: we cannot make a nice request */ |
| 2092 | return CURLE_OUT_OF_MEMORY; |
| 2093 | } |
| 2094 | return CURLE_OK; |
| 2095 | } |
| 2096 | |
| 2097 | /* |
| 2098 | * Append the request-target to the HTTP request |
| 2099 | */ |
| 2100 | static CURLcode http_target(struct Curl_easy *data, |
| 2101 | struct dynbuf *r) |
| 2102 | { |
| 2103 | CURLcode result = CURLE_OK; |
| 2104 | const char *path = data->state.up.path; |
| 2105 | const char *query = data->state.up.query; |
| 2106 | #ifndef CURL_DISABLE_PROXY |
| 2107 | struct connectdata *conn = data->conn; |
| 2108 | #endif |
| 2109 | |
| 2110 | if(data->set.str[STRING_TARGET]) { |
| 2111 | path = data->set.str[STRING_TARGET]; |
| 2112 | query = NULL; |
| 2113 | } |
| 2114 |