CVE-2026-80255: secure cookie attribute bypass with tab
What the report claims
A Set-Cookie header using HTAB before the Secure attribute causes curl to store the cookie without its Secure flag, leading to plaintext transmission of the cookie over subsequent HTTP requests.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-80255 secure cookie attribute bypass with tab
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 provided code at lib/cookie.c:610-656 shows a do/while loop that consumes ';' but does not skip blank characters (space or HTAB) before parsing the next attribute name.
- curlx_str_cspn at line 614 rejects HTAB in its reject set, so a leading HTAB causes the attribute parse to fail silently.
- The Secure flag parsing (parse_flag at line 642) is inside the skipped if block, so the cookie is stored without the Secure flag.
- The cookie is then sent over plaintext HTTP, violating the confidentiality boundary the Secure attribute establishes.
- The fix is straightforward: add curlx_str_passblanks(&ptr) after the ';' is consumed.
- The external RFC 6265 claim about HTAB as valid whitespace is not explicitly confirmed by the provided excerpt, but curl's own ISBLANK macro treats HTAB as blank, indicating the project considers it valid whitespace.
How this analysis ran 5 claims · 15 citations verified
Checked against curl tested commit c2676bf9e639 (c2676bf9e639). Dev 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. The code confirms the flaw: parse_cookie_header() in lib/cookie.c does not skip HTAB after consuming ';', causing attributes starting with HTAB to be silently dropped. The Secure flag is lost and the cookie leaks over HTTP. We will add curlx_str_passblanks() after the ';' consumption and a regression test. The fix is targeted for the next release.
Inspect all claims and source code 5 claims · 6 code excerpts
Report claims
parse_cookie_header() fails to parse attributes that start with HTAB, causing the Secure flag to be silently dropped
Read the reasoning
The do/while loop at lines 610-656 parses each attribute. After consuming ';', if the next character is HTAB, curlx_str_cspn at line 614 returns failure (STRE_SHORT) because HTAB is in its reject set. The if block (lines 615-655) containing parse_flag for 'secure' is skipped. Then curlx_str_single at line 656 fails to match ';', exiting the loop. The cookie is stored at lines 658-662 without the Secure flag.
In lib/cookie.c, parse_cookie_header() uses a do/while loop that does not skip HTAB after consuming ';'
Read the reasoning
The loop at lines 610-656 has no call to curlx_str_passblanks or equivalent blank-skipping after consuming ';' at line 656 and before the next curlx_str_cspn at line 614. The helper curlx_str_passblanks exists (strparse.c:313-317) and uses ISBLANK which includes HTAB (curl_ctype.h:43), but it is not invoked here.
A Set-Cookie header containing a literal HTAB (0x09) before the Secure attribute, e.g. 'Set-Cookie: sid=SECRET;\tSecure'
Read the reasoning
A Set-Cookie header like 'sid=SECRET;\tSecure' leaves ptr at HTAB after the ';' is consumed. The next curlx_str_cspn call sees HTAB as the first character, which is in its reject set, so it returns failure and the attribute 'Secure' is never parsed.
The cookie is stored without the Secure flag and is subsequently sent over plaintext HTTP on follow‑up requests to the same host
Read the reasoning
Since the Secure attribute is never parsed (parse_flag not called), the cookie is stored without the Secure flag. Per curl's cookie storage model, cookies without the Secure flag are sent over both HTTPS and HTTP, causing the secret to leak on plaintext connections.
Steps 1‑8 in the report reproduce the bug by requesting /htab, saving the cookie jar, and then making an HTTP request that echoes the cookie
Read the reasoning
The reproduction steps exercise the exact code path: HTTPS request sets cookie with HTAB before Secure, cookie jar saves it without Secure flag, subsequent HTTP request sends the cookie. The provided code confirms this path exists.
What the report says about other projects
not addressed HTAB is valid optional whitespace per RFC 6265 section 5.2 and the HTTP grammar (OWS)
Read the original report (excerpt)
Source code
| 283 | int curlx_str_cspn(const char **linep, struct Curl_str *out, |
| 284 | const char *reject) |
| 285 | { |
| 286 | const char *s = *linep; |
| 287 | size_t len; |
| 288 | DEBUGASSERT(linep && *linep); |
| 289 | |
| 290 | len = strcspn(s, reject); |
| C3: strcspn returns 0 length when first character is in reject set, causing STRE_SHORT | |
| 291 | if(len) { |
| 292 | out->str = s; |
| 293 | out->len = len; |
| 294 | *linep = &s[len]; |
| 295 | return STRE_OK; |
| 296 | } |
| 297 | curlx_str_init(out); |
| 298 | return STRE_SHORT; |
| 299 | } |
| 43 | #define ISBLANK(x) (((x) == ' ') || ((x) == '\t')) |
| C2: ISBLANK macro defines blank as space or HTAB |
| 313 | void curlx_str_passblanks(const char **linep) |
| C2: curlx_str_passblanks exists and skips ISBLANK (space or HTAB) but is not called in parse_cookie_header | |
| 314 | { |
| 315 | while(ISBLANK(**linep)) |
| 316 | (*linep)++; /* move over it */ |
| 317 | } |
| 587 | static CURLcode |
| C5: parse_cookie_header is the entry point for Set-Cookie header parsing | |
| 588 | parse_cookie_header(struct Curl_easy *data, |
| 589 | struct Cookie *co, |
| 590 | const struct CookieInfo *ci, |
| 591 | bool *okay, /* if the cookie was fine */ |
| 592 | const char *ptr, /* the header */ |
| 593 | const char *domain, /* default domain */ |
| 594 | /* full path used when this cookie is set */ |
| 595 | const char *path, |
| 596 | bool secure_origin) |
| 597 | { |
| 598 | /* This line was read off an HTTP-header */ |
| 599 | time_t now = 0; |
| 600 | size_t linelength = strlen(ptr); |
| 601 | CURLcode result = CURLE_OK; |
| 602 | struct Curl_str cookie[COOKIE_PIECES]; |
| 603 | *okay = FALSE; |
| 604 | if(linelength > MAX_COOKIE_LINE) |
| 605 | /* discard overly long lines at once */ |
| 606 | return CURLE_OK; |
| 607 | |
| 608 | /* memset instead of initializer because gcc 4.8.1 is silly */ |
| 609 | memset(cookie, 0, sizeof(cookie)); |
| 610 | do { |
| C2: do/while loop starts here with no blank-skipping before curlx_str_cspn | |
| 611 | struct Curl_str name; |
| 612 | |
| 613 | /* we have a <name>=<value> pair or a stand-alone word here */ |
| 614 | if(!curlx_str_cspn(&ptr, &name, ";\t\r\n=")) { |
| C1: curlx_str_cspn rejects HTAB in its reject set ";\t\r\n=", so an attribute starting with HTAB fails to parse | |
| C3: curlx_str_cspn called with reject set including HTAB; a leading HTAB causes immediate failure | |
| 615 | struct Curl_str val; |
| 616 | bool sep = FALSE; |
| 617 | curlx_str_trimblanks(&name); |
| 618 | |
| 619 | if(invalid_octets(curlx_str(&name), curlx_strlen(&name))) { |
| 620 | infof(data, "invalid octets in name, cookie dropped"); |
| 621 | return CURLE_OK; |
| 622 | } |
| 623 | |
| 624 | if(!curlx_str_single(&ptr, '=')) { |
| 625 | sep = TRUE; /* a '=' was used */ |
| 626 | if(!curlx_str_cspn(&ptr, &val, ";\r\n")) |
| 627 | curlx_str_trimblanks(&val); |
| 628 | |
| 629 | if(invalid_octets(curlx_str(&val), curlx_strlen(&val))) { |
| 630 | infof(data, "invalid octets in value, cookie dropped"); |
| 631 | return CURLE_OK; |
| 632 | } |
| 633 | } |
| 634 | else |
| 635 | curlx_str_init(&val); |
| 636 | |
| 637 | if(!curlx_strlen(&cookie[COOKIE_NAME])) { |
| 638 | if(!parse_first_pair(data, co, cookie, &name, &val, sep)) |
| 639 | return CURLE_OK; |
| 640 | } |
| 641 | else if(!sep) { |
| 642 | if(!parse_flag(data, co, ci, &name, secure_origin)) |
| C1: parse_flag for Secure attribute is inside the if block that is skipped when curlx_str_cspn fails | |
| C4: parse_flag for 'secure' is only called inside the if block that is skipped | |
| 643 | return CURLE_OK; |
| 644 | } |
| 645 | else if(curlx_str_casecompare(&name, "path")) |
| 646 | cookie[COOKIE_PATH] = val; |
| 647 | else if(curlx_str_casecompare(&name, "domain") && curlx_strlen(&val)) { |
| 648 | if(!parse_domain(data, co, &cookie[COOKIE_DOMAIN], &val, &domain)) |
| 649 | return CURLE_OK; |
| 650 | } |
| 651 | else if(curlx_str_casecompare(&name, "max-age") && curlx_strlen(&val)) |
| 652 | parse_maxage(co, &val, &now); |
| 653 | else if(curlx_str_casecompare(&name, "expires") && curlx_strlen(&val)) |
| 654 | parse_expires(co, &val, &now); |
| 655 | } |
| 656 | } while(!curlx_str_single(&ptr, ';')); |
| C1: curlx_str_single(&ptr, ';') fails because current byte is HTAB, not ';', causing loop exit | |
| C2: Loop condition consumes ';' but does not skip blanks before next iteration | |
| 657 | |
| 658 | if(curlx_strlen(&cookie[COOKIE_NAME])) { |
| C1: Cookie is still stored via storecookie() even though Secure flag was never parsed | |
| C4: Cookie is stored if COOKIE_NAME has length, regardless of whether Secure flag was parsed | |
| 659 | /* the header was fine, now store the data */ |
| 660 | result = storecookie(co, &cookie[0], path, domain); |
| C4: storecookie called with cookie data that lacks Secure flag | |
| C5: storecookie persists the cookie to the cookie jar | |
| 661 | if(!result) |
| 662 | *okay = TRUE; |
| 663 | } |
| 664 | return result; |
| 665 | } |
Show 8 lines (not cited by the judge)
| 138 | int curlx_str_single(const char **linep, char byte) |
| 139 | { |
| 140 | DEBUGASSERT(linep && *linep); |
| 141 | if(**linep != byte) |
| 142 | return STRE_BYTE; |
| 143 | (*linep)++; /* move over it */ |
| 144 | return STRE_OK; |
| 145 | } |
Show 42 lines (not cited by the judge)
| 84 | while(<CURL_H>) { |
| 85 | my $l = $_; |
| 86 | if($fl) { |
| 87 | # continued deprecation |
| 88 | if($l =~ /(.*)\),/) { |
| 89 | $fl .= $1; |
| 90 | |
| 91 | # the end |
| 92 | my @p = split(/, */, $fl); |
| 93 | add($p[0], $p[1], $p[2]); |
| 94 | undef $fl; |
| 95 | } |
| 96 | else { |
| 97 | # another line to append |
| 98 | chomp $l; |
| 99 | $fl .= $l; |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | if(/^ *CURLOPTDEPRECATED\((.*)/) { |
| 104 | $fl = $1; |
| 105 | chomp $fl; |
| 106 | } |
| 107 | |
| 108 | if(/^ *CURLOPT\(([^,]*), ([^,]*), (\d+)\)/) { |
| 109 | my($opt, $type, $num) = ($1, $2, $3); |
| 110 | add($opt, $type, $num); |
| 111 | } |
| 112 | |
| 113 | # alias for an older option |
| 114 | # old = new |
| 115 | if(/^#define (CURLOPT_[^ ]*) *(CURLOPT_\S*)/) { |
| 116 | my ($o, $n) = ($1, $2); |
| 117 | # skip obsolete ones |
| 118 | if(($n !~ /OBSOLETE/) && ($o !~ /OBSOLETE/)) { |
| 119 | $o =~ s/^CURLOPT_//; |
| 120 | $n =~ s/^CURLOPT_//; |
| 121 | $alias{$o} = $n; |
| 122 | push @names, $o; |
| 123 | } |
| 124 | } |
| 125 | } |