HexBrain

curl · Report #4129642

HSTS cache file: three trailing dots silently disable HSTS for that host

Informativestatus
mediumseverity
8upvotes
20 Oct 2026disclosed

How it was found

Researcher: Maturi Jaswanth Sai Program: curl (HackerOne) Asset: github.com/curl/curl, lib/hsts.c Affected: curl 8.20.0, 8.21.0, 8.22.0 and 8.23.0 (tag curl-8_23_0, 2026-10-14), plus master eb2b201a (8.23.1-DEV, 2026-10-19) Weakness: CWE-295 / CWE-693, an HSTS pin that silently stops applying

Before we went into report to be clear I have created separate file for reproduce steps to validate manually for the team so check that.

What happens

CURLOPT_HSTS (and curl --hsts) reads cache lines of the form host "expiry". If the host on that line ends in three or more dots, curl stores the entry with exactly one dot still attached, for example example.com.. A later request to the clean name example.com never matches that entry, so the HSTS pin does nothing and curl sends the request over plain HTTP. Nothing is logged, and the load function returns CURLE_OK.

The malformed entry is also written back to the cache file, so it survives restarts.

Root cause

Three places strip a single trailing dot, none of them loop:

hsts_add_host_expire() (called for every line read from a file), 8.23.0 line 459:

  if(hostlen && (host[hostlen - 1] == '.'))
    /* strip off any trailing dot */
    hostlen--;

  if(hostlen) {
    /* only add it if not already present */
    e = hsts_check(h, host, hostlen, FALSE);
    if(!e)
      result = hsts_create(h, host, hostlen, subdomain, expires);

hsts_create() (line 119):

  if(hlen && (hostname[hlen - 1] == '.'))
    /* strip off any trailing dot */
    --hlen;

hsts_check() (line 177, exact match at line 201):

    if(hostname[hlen - 1] == '.')
      /* remove the trailing dot */
      --hlen;
    ...
      if((hlen == ntail) && curl_strnequal(hostname, sts->host, hlen))
        return sts;

For example.com...:

  1. hsts_add_host_expire() removes one dot, leaving two. It calls hsts_check() for the duplicate check, which trims one more dot in its own local hlen but does not change the caller's hostlen. Nothing matches.
  2. hsts_create() is then called with the caller's length, trims one more dot, and stores example.com..

Two dots survive both strips and store as example.com, which is why the 2-dot case works correctly. Three dots leave one behind.

The live URL path already rejects this input: urlapi.c hostname_check() returns CURLUE_BAD_HOSTNAME for more than one trailing dot. The HSTS file loader never calls it.

Reproduce

One file, one command, no build needed (curl with HSTS support and python3):

./poc_hsts.py

It runs the 3-dot case and the 2-dot control by itself.

It writes an HSTS file with 3 trailing dots, listens on localhost, runs curl --hsts ... --connect-to ... http://example.com/, prints the bytes curl sends while the request is happening, and then repeats with 2 dots as a control.

Manual version, exact commands:

printf 'example.com... "20991231 10:00:00"\n' > hsts.txt
nc -l 127.0.0.1 8768 | xxd -l 64 &
curl -v --noproxy '*' --hsts hsts.txt \
  --connect-to ::127.0.0.1:8768 http://example.com/

--connect-to only redirects the TCP connection; curl still believes it is talking to http://example.com/, which is the decision under test.

Expected on 8.23.0: the listener prints 47 45 54 20 ... (GET ), curl logs > GET / HTTP/1.1, and the HSTS file is rewritten as example.com. "20991231 10:00:00".

Change the line to example.com.. (two dots) and the listener prints 16 03 01 ..., a TLS ClientHello, because curl upgrades the URL.

Full step-by-step walkthrough with both the script and the manual path: [REPRODUCE.md].

Evidence

Raw bytes received by the local listener:

HSTS file linefirst bytes on the wireresult
example.com...474554202f20485454502f312e310d0a (GET / HTTP/1.1)plain HTTP, HSTS bypassed
example.com..1603010697... (TLS ClientHello)upgraded, control OK
example.com.160301... (TLS ClientHello)upgraded, control OK

Cache file after the run (3-dot case):

# Your HSTS cache. https://curl.se/docs/hsts.html
# This file was generated by libcurl! Edit at your own risk.
example.com. "20991231 10:00:00"

Screenshot of the full run against 8.23.0 (built from the curl-8_23_0 tag, which reports itself as 8.23.0-DEV). Both cases are in the one capture:

The same result was reproduced with the curl CLI built from 8.22.0 and 8.23.0, and with the 8.21.0 distro package. The existing trailing-dot tests (test440, test441) only cover a single dot on the URL side, never multiple dots on a hostname in the cache file, which is why this went unnoticed.

What I ruled out

  • urlapi.c rejects more than one trailing dot on live URLs, so this cannot be triggered by a server response or a crafted URL. The file loader does not use that check.
  • Curl_hsts_parse() handles Strict-Transport-Security response headers and passes an already validated hostname to hsts_create(); the 3-dot input cannot reach it that way.
  • test440 and test441 (from PR #8821, the last trailing-dot change in this file) exercise a trailing dot in the URL, not several dots in a file entry. test2582 is about cookies, not HSTS.
  • CVE-2026-8924 (trailing-dot cookie supercookie) only changed lib/cookie.c and its tests, not lib/hsts.c.
  • Searched curl issues/PRs for hsts dot, hsts trailing, CURLOPT_HSTS and found no report for this file-loader case.

Suggested fix

Strip trailing dots in a loop in one place, for example in hsts_add_host_expire() before the calls to hsts_check() and hsts_create():

while(hostlen && host[hostlen - 1] == '.')
  hostlen--;

or reject any hostname with two or more trailing dots at load time, matching urlapi.c. Add tests for the 3-dot and 4-dot cases next to test440 and test441.

Impact

An HSTS cache file entry that says example.com should be pinned is silently turned into an entry for example.com., and the host loses its HSTS protection. A later http://example.com/ request goes out in cleartext and can be stripped or modified on the network.

The weak point is the input: this needs a malformed HSTS file line, and anyone who can edit that file can delete the line instead. That makes it a silent failure of a security control rather than an attacker capability. It is the same shape as CVE-2023-46219 (a filename length silently cleared the whole cache, rated Low), where the problem is that the protection the operator asked for disappears without an error. I rate this Low.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original