CVE-2026-87862: HSTS cache import drops longer-lived child
What the report claims
When loading an HSTS cache, curl silently drops a longer-lived child entry beneath an includeSubDomains parent, causing the child's still-valid HSTS policy to be lost after the parent expires, leading to plaintext HTTP requests instead of HTTPS upgrades.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-87862 HSTS cache import drops longer-lived child
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/hsts.c:464 calls hsts_check with subdomain=TRUE during cache import, enabling hierarchical matching for deduplication.
- hsts_check (lines 190-198) returns a parent includeSubDomains entry as a match for a child hostname.
- The subsequent exact-match check (lines 470-471) fails because hostnames differ, so the child is neither created nor updated — it is silently dropped.
- Network-learned policy processing (Curl_hsts_parse at line 296) correctly uses exact-only lookup (FALSE) for the same purpose.
- After the parent expires, the child's independently valid HSTS policy is gone, causing plaintext HTTP requests where HTTPS upgrade should occur — a security impact per RFC 6797's independent-policy model.
How this analysis ran 5 claims · 11 citations verified
Checked against curl master on 2026-08-26 (0b5759caa299). 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.
The report is correct: hsts_add_host_expire() at lib/hsts.c:464 uses hierarchical matching (subdomain=TRUE) for import deduplication, which silently drops a longer-lived child when a parent with includeSubDomains is already present. The fix — changing that call to subdomain=FALSE to match Curl_hsts_parse() at line 296 — is sound. This affects released versions from 8.11.0 onward. Thank you for the clear analysis and reproduction steps.
Inspect all claims and source code 5 claims · 5 code excerpts
Report claims
Loading an HSTS cache silently drops a child entry when a parent with includeSubDomains is imported first and the child has a later expiry.
Read the reasoning
When a parent .example.test with includeSubDomains is loaded first, hsts_check at line 464 (with subdomain=TRUE) returns the parent entry for child.example.test. The exact-match check at line 470 fails because hostnames differ, so neither creation (line 468) nor update (lines 474-477) occurs — the child is silently dropped.
The flaw occurs in lib/hsts.c at line 464 where hsts_check() is invoked with subdomain=TRUE.
Read the reasoning
The call at line 464 passes the subdomain parameter (which is TRUE for includeSubDomains entries per lines 455-458), causing hierarchical matching during import deduplication.
Importing an HSTS cache file or using CURLOPT_HSTSREADFUNCTION callback with a parent entry listed before a child entry that has a later expiry.
Read the reasoning
Both cache file parsing and CURLOPT_HSTSREADFUNCTION callback ultimately call hsts_add_host_expire. Entries with leading '.' (includeSubDomains) trigger the flawed path when a parent is listed before a longer-lived child.
After the parent expires, the child's independently valid HSTS policy is lost, causing curl to send plaintext HTTP requests instead of upgrading to HTTPS.
Read the reasoning
Once the child is dropped at import, only the parent remains in the cache. After the parent expires (and is removed per line 183-187), no HSTS policy exists for the child hostname, so curl will not upgrade HTTP to HTTPS for that host.
Create an HSTS cache with parent and child entries where parent is listed first and child expires later, load the cache, advance clock past parent expiry but before child expiry, then request the child hostname; observe plaintext HTTP instead of HTTPS upgrade.
Read the reasoning
The reproduction steps follow the code path: parent loaded first → hierarchical match on child import → child dropped → parent expires → no policy for child. The control (child listed first) works because the child is created before the parent exists to match it.
Read the original report (excerpt)
Source code
| 429 | static CURLcode hsts_add_host_expire(struct hsts *h, |
| C3: hsts_add_host_expire is the import entry point for both file and callback loads | |
| 430 | const char *host, size_t hostlen, |
| 431 | const char *expire, size_t explen, |
| 432 | bool subdomain) /* default */ |
| 433 | { |
| 434 | CURLcode result = CURLE_OK; |
| 435 | struct stsentry *e; |
| 436 | char dbuf[MAX_HSTS_DATELEN + 1]; |
| 437 | time_t expires = 0; |
| 438 | time_t now = time(NULL); |
| 439 | |
| 440 | /* The date parser works on a null-terminated string. */ |
| 441 | if(explen > MAX_HSTS_DATELEN) |
| 442 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 443 | memcpy(dbuf, expire, explen); |
| 444 | dbuf[explen] = 0; |
| 445 | |
| 446 | if(!strcmp(dbuf, UNLIMITED)) |
| 447 | expires = TIME_T_MAX; |
| 448 | else |
| 449 | Curl_getdate_capped(dbuf, &expires); |
| 450 | |
| 451 | if(expires <= now) |
| C4: Expired entries are skipped at load time (expires <= now) | |
| 452 | /* this entry already expired */ |
| 453 | return CURLE_OK; |
| 454 | |
| 455 | if(host[0] == '.') { |
| C3: Leading '.' sets subdomain=TRUE for includeSubDomains entries | |
| 456 | host++; |
| 457 | hostlen--; |
| 458 | subdomain = TRUE; |
| 459 | } |
| 460 | if(hostlen && (host[hostlen - 1] == '.')) |
| 461 | /* strip off any trailing dot */ |
| 462 | hostlen--; |
| 463 | |
| 464 | if(hostlen) { |
| C1: hsts_check called with subdomain=TRUE during cache import | |
| C2: e = hsts_check(h, host, hostlen, subdomain); with subdomain=TRUE | |
| C5: Import deduplication uses hierarchical match | |
| 465 | /* only add it if not already present */ |
| 466 | e = hsts_check(h, host, hostlen, subdomain); |
| 467 | if(!e) |
| 468 | result = hsts_create(h, host, hostlen, subdomain, expires); |
| C1: Creation branch only runs when hsts_check returns NULL; parent match prevents creation | |
| 469 | /* 'host' is not necessarily null-terminated */ |
| 470 | else if((hostlen == strlen(e->host) && |
| C1: Update branch only runs on exact hostname match; child hostname differs from parent | |
| 471 | curl_strnequal(host, e->host, hostlen))) { |
| 472 | /* the same hostname, use the largest expire time and keep the strictest |
| 473 | subdomain policy */ |
| 474 | if(expires > e->expires) |
| 475 | e->expires = expires; |
| 476 | if(subdomain) |
| 477 | e->includeSubDomains = TRUE; |
| 478 | } |
| 479 | } |
| 480 | return result; |
| 481 | } |
| 161 | UNITTEST struct stsentry *hsts_check(struct hsts *h, const char *hostname, |
| 162 | size_t hlen, bool subdomain); |
| 163 | UNITTEST struct stsentry *hsts_check(struct hsts *h, const char *hostname, |
| 164 | size_t hlen, bool subdomain) |
| 165 | { |
| 166 | struct stsentry *bestsub = NULL; |
| 167 | if(h) { |
| 168 | time_t now = time(NULL); |
| 169 | struct Curl_llist_node *e; |
| 170 | struct Curl_llist_node *n; |
| 171 | size_t blen = 0; |
| 172 | |
| 173 | if((hlen > MAX_HSTS_HOSTLEN) || !hlen) |
| 174 | return NULL; |
| 175 | if(hostname[hlen - 1] == '.') |
| 176 | /* remove the trailing dot */ |
| 177 | --hlen; |
| 178 | |
| 179 | for(e = Curl_llist_head(&h->list); e; e = n) { |
| 180 | struct stsentry *sts = Curl_node_elem(e); |
| 181 | size_t ntail; |
| 182 | n = Curl_node_next(e); |
| 183 | if(sts->expires <= now) { |
| C4: hsts_check removes expired entries during lookup | |
| 184 | /* remove expired entries */ |
| 185 | Curl_node_remove(&sts->node); |
| 186 | hsts_free(sts); |
| 187 | continue; |
| 188 | } |
| 189 | ntail = strlen(sts->host); |
| 190 | if((subdomain && sts->includeSubDomains) && (ntail < hlen)) { |
| C1: hsts_check returns parent match when subdomain=TRUE and parent has includeSubDomains | |
| 191 | size_t offs = hlen - ntail; |
| 192 | if((hostname[offs - 1] == '.') && |
| 193 | curl_strnequal(&hostname[offs], sts->host, ntail) && |
| 194 | (ntail > blen)) { |
| 195 | /* save the tail match with the longest tail */ |
| 196 | bestsub = sts; |
| 197 | blen = ntail; |
| 198 | } |
| 199 | } |
| 200 | /* avoid curl_strequal because the hostname is not null-terminated */ |
| 201 | if((hlen == ntail) && curl_strnequal(hostname, sts->host, hlen)) |
| 202 | return sts; |
| 203 | } |
| 204 | } |
| 205 | return bestsub; |
| 206 | } |
| 208 | CURLcode Curl_hsts_parse(struct hsts *h, const char *hostname, |
| 209 | const char *header) |
| 210 | { |
| 211 | const char *p = header; |
| 212 | curl_off_t expires = 0; |
| 213 | bool gotma = FALSE; |
| 214 | bool gotinc = FALSE; |
| 215 | bool subdomains = FALSE; |
| 216 | struct stsentry *sts; |
| 217 | time_t now = time(NULL); |
| 218 | size_t hlen = strlen(hostname); |
| 219 | |
| 220 | if(Curl_host_is_ipnum(hostname)) |
| 221 | /* "explicit IP address identification of all forms is excluded." |
| 222 | / RFC 6797 */ |
| 223 | return CURLE_OK; |
| 224 | |
| 225 | do { |
| 226 | struct Curl_str word; |
| 227 | struct Curl_str val = { 0 }; |
| 228 | int rc; |
| 229 | bool assign = FALSE; |
| 230 | |
| 231 | do { |
| 232 | curlx_str_passblanks(&p); |
| 233 | if(*p == ';') |
| 234 | p++; |
| 235 | else |
| 236 | break; |
| 237 | } while(1); |
| 238 | if(curlx_str_cspn(&p, &word, ";=\r\n \t")) |
| 239 | break; |
| 240 | |
| 241 | curlx_str_passblanks(&p); |
| 242 | if(!curlx_str_single(&p, '=')) { |
| 243 | assign = TRUE; |
| 244 | curlx_str_passblanks(&p); |
| 245 | |
| 246 | if(*p == '\"') { |
| 247 | if(curlx_str_quotedword(&p, &val, MAX_HSTS_LINE)) |
| 248 | break; |
| 249 | } |
| 250 | else { |
| 251 | if(curlx_str_cspn(&p, &val, ", ;\r\n")) |
| 252 | break; |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | if(assign && curlx_str_casecompare(&word, "max-age")) { |
| 257 | const char *vp = curlx_str(&val); |
| 258 | if(gotma) |
| 259 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 260 | rc = curlx_str_number(&vp, &expires, TIME_T_MAX); |
| 261 | if(rc == STRE_OVERFLOW) |
| 262 | expires = CURL_OFF_T_MAX; |
| 263 | else if(rc) |
| 264 | /* invalid max-age */ |
| 265 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 266 | |
| 267 | gotma = TRUE; |
| 268 | } |
| 269 | else if(curlx_str_casecompare(&word, "includesubdomains")) { |
| 270 | if(gotinc) |
| 271 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 272 | subdomains = TRUE; |
| 273 | gotinc = TRUE; |
| 274 | } |
| 275 | } while(*p); |
| 276 | |
| 277 | if(!gotma) |
| 278 | /* max-age is mandatory */ |
| 279 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 280 | |
| 281 | if(!expires) { |
| 282 | /* remove the entry if present verbatim (without subdomain match) */ |
| 283 | sts = hsts_check(h, hostname, hlen, FALSE); |
| 284 | if(sts) { |
| 285 | Curl_node_remove(&sts->node); |
| 286 | hsts_free(sts); |
| 287 | } |
| 288 | return CURLE_OK; |
| 289 | } |
| 290 | |
| 291 | if(CURL_OFF_T_MAX - now < expires) |
| 292 | /* would overflow, use maximum value */ |
| 293 | expires = CURL_OFF_T_MAX; |
| 294 | else |
| 295 | expires += now; |
| 296 | |
| C5: Network-learned path uses exact match (FALSE) for comparison | |
| 297 | /* check if it already exists */ |
| 298 | sts = hsts_check(h, hostname, hlen, FALSE); |
| 299 | if(sts) { |
| 300 | /* update these fields */ |
| 301 | sts->expires = expires; |
| 302 | sts->includeSubDomains = subdomains; |
| 303 | } |
| 304 | else |
| 305 | return hsts_create(h, hostname, hlen, subdomains, expires); |
| 306 | |
| 307 | return CURLE_OK; |
| 308 | } |
Show 1 lines (not cited by the judge)
| 1049 | #define TRUE true |
Show 99 lines (not cited by the judge)
| 208 | CURLcode Curl_hsts_parse(struct hsts *h, const char *hostname, |
| 209 | const char *header) |
| 210 | { |
| 211 | const char *p = header; |
| 212 | curl_off_t expires = 0; |
| 213 | bool gotma = FALSE; |
| 214 | bool gotinc = FALSE; |
| 215 | bool subdomains = FALSE; |
| 216 | struct stsentry *sts; |
| 217 | time_t now = time(NULL); |
| 218 | size_t hlen = strlen(hostname); |
| 219 | |
| 220 | if(Curl_host_is_ipnum(hostname)) |
| 221 | /* "explicit IP address identification of all forms is excluded." |
| 222 | / RFC 6797 */ |
| 223 | return CURLE_OK; |
| 224 | |
| 225 | do { |
| 226 | curlx_str_passblanks(&p); |
| 227 | if(curl_strnequal("max-age", p, 7)) { |
| 228 | bool quoted = FALSE; |
| 229 | int rc; |
| 230 | |
| 231 | if(gotma) |
| 232 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 233 | |
| 234 | p += 7; |
| 235 | curlx_str_passblanks(&p); |
| 236 | if(curlx_str_single(&p, '=')) |
| 237 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 238 | curlx_str_passblanks(&p); |
| 239 | |
| 240 | if(!curlx_str_single(&p, '\"')) |
| 241 | quoted = TRUE; |
| 242 | |
| 243 | rc = curlx_str_number(&p, &expires, TIME_T_MAX); |
| 244 | if(rc == STRE_OVERFLOW) |
| 245 | expires = CURL_OFF_T_MAX; |
| 246 | else if(rc) |
| 247 | /* invalid max-age */ |
| 248 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 249 | |
| 250 | if(quoted) { |
| 251 | if(*p != '\"') |
| 252 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 253 | p++; |
| 254 | } |
| 255 | gotma = TRUE; |
| 256 | } |
| 257 | else if(curl_strnequal("includesubdomains", p, 17)) { |
| 258 | if(gotinc) |
| 259 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 260 | subdomains = TRUE; |
| 261 | p += 17; |
| 262 | gotinc = TRUE; |
| 263 | } |
| 264 | else { |
| 265 | /* unknown directive, do a lame attempt to skip */ |
| 266 | while(*p && (*p != ';')) |
| 267 | p++; |
| 268 | } |
| 269 | |
| 270 | curlx_str_passblanks(&p); |
| 271 | if(*p == ';') |
| 272 | p++; |
| 273 | } while(*p); |
| 274 | |
| 275 | if(!gotma) |
| 276 | /* max-age is mandatory */ |
| 277 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 278 | |
| 279 | if(!expires) { |
| 280 | /* remove the entry if present verbatim (without subdomain match) */ |
| 281 | sts = hsts_check(h, hostname, hlen, FALSE); |
| 282 | if(sts) { |
| 283 | Curl_node_remove(&sts->node); |
| 284 | hsts_free(sts); |
| 285 | } |
| 286 | return CURLE_OK; |
| 287 | } |
| 288 | |
| 289 | if(CURL_OFF_T_MAX - now < expires) |
| 290 | /* would overflow, use maximum value */ |
| 291 | expires = CURL_OFF_T_MAX; |
| 292 | else |
| 293 | expires += now; |
| 294 | |
| 295 | /* check if it already exists */ |
| 296 | sts = hsts_check(h, hostname, hlen, FALSE); |
| 297 | if(sts) { |
| 298 | /* update these fields */ |
| 299 | sts->expires = expires; |
| 300 | sts->includeSubDomains = subdomains; |
| 301 | } |
| 302 | else |
| 303 | return hsts_create(h, hostname, hlen, subdomains, expires); |
| 304 | |
| 305 | return CURLE_OK; |
| 306 | } |