✓ProveIt
Examples / Report #3972357 Saved analysis

CVE-2026-87862: HSTS cache import drops longer-lived child

curl · 2026-08-2625.8s analysis · $0.020 in model calls

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

Code supports the claim

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.

1 · Grounding
grounded
no model · 12 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 2593 tokens · $0.0003
3 · Evidence
5 code snippets
real curl source at 0b5759caa299 · 4 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
14919 tokens · 8.8s · $0.0199
5 · Receipts
11 of 11 citations verified
every citation must point at code the judge was shown
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

C1flaw supported

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.

C2location supported

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.

C3trigger supported

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.

C4impact supported

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.

C5reproduction supported

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)

Full report on HackerOne ↗

# HSTS cache import silently drops a longer-lived child beneath an includeSubDomains parent ## Summary Loading an HSTS cache (file or `CURLOPT_HSTSREADFUNCTION` callback) can silently discard a child entry. When a live `includeSubDomains` parent is imported before a longer-lived `includeSubDomains` child, only the parent is retained. After the parent expires, the child's independently valid HSTS policy is gone and curl sends a plaintext HTTP request instead of upgrading to HTTPS. This violates the independent-policy model required by RFC 6797 §5.3. ## Affected versions The file-load path regressed in commit `a94973805df96269bf3f3bf0a20ccb9887313316` (CVE-2024-9681 fix), first released in curl 8.11.0. The callback-load path inherited the same logic in commit `e65ba1bd349a7affb09275a442b6a0837df079d9`, released in curl 8.20.0. Verified at `c2676bf9e639e31e44b12a2cc82cf2046231b056`. ## Root cause `hsts_add_host_expire()` in `lib/hsts.c` calls `hsts_check(h, host, hostlen, subdomain)` with `subdomain=TRUE` at line 464. `hsts_check()` performs hierarchical matching: if the parent `.example.test` is already loaded with `includeSubDomains`, it returns the parent as a match for `child.example.test`. The caller creates an entry only when no match exists, and its update branch runs only when the returned hostname exactly matches the imported hostname. An ancestor match therefore causes neither an update nor a creation — the child is silently dropped. Network-learned `Strict-Transport-Security` processing does not have this problem because `Curl_hsts_parse()` uses exact-only lookup (`hsts_check(..., FALSE)`) at line 296. ## Reproduction steps Build curl from current master (or any version since 8.11.0) with HSTS support enabled and ENABLE_DEBUG=ON (for CURL_TIME clock control). Create an HSTS cache file with two includeSubDomains entries where the parent is listed first and the child has a later expiry, e.g.: .example.test "20241010 00:00:00" .child.example.test "20251010 00:00:00" Set the clock to a time when both entries are still valid (e.g. CURL_TIME=1728465947, October 2024). Run any curl request with --hsts <cache-file> to force curl to load and rewrite the cache. Inspect the rewritten cache file — it now contains only one entry (the parent). The child entry has been silently dropped. Advance the clock past the parent's expiry but before the child's (e.g. …

Source code

lib/hsts.clines 429–481 · definition of hsts_add_host_expire
429static 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}
lib/hsts.clines 161–206 · definition of hsts_check
161UNITTEST struct stsentry *hsts_check(struct hsts *h, const char *hostname,
162 size_t hlen, bool subdomain);
163UNITTEST 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}
lib/hsts.clines 208–308 · definition of Curl_hsts_parse
208CURLcode 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}
lib/curl_setup.hlines 1049–1049 · definition of TRUE
Show 1 lines (not cited by the judge)
1049#define TRUE true
lib/hsts.c@curl-8_21_0lines 208–306 · definition of Curl_hsts_parse as in release curl-8_21_0; differs from master; commits that changed it since: 4080ace060 hsts: only match the exact strings
Show 99 lines (not cited by the judge)
208CURLcode 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}