curl · Report #4129642
HSTS cache file: three trailing dots silently disable HSTS for that host
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...:
hsts_add_host_expire()removes one dot, leaving two. It callshsts_check()for the duplicate check, which trims one more dot in its own localhlenbut does not change the caller'shostlen. Nothing matches.hsts_create()is then called with the caller's length, trims one more dot, and storesexample.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 line | first bytes on the wire | result |
|---|---|---|
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.crejects 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()handlesStrict-Transport-Securityresponse headers and passes an already validated hostname tohsts_create(); the 3-dot input cannot reach it that way.test440andtest441(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.test2582is about cookies, not HSTS.- CVE-2026-8924 (trailing-dot cookie supercookie) only changed
lib/cookie.cand its tests, notlib/hsts.c. - Searched curl issues/PRs for
hsts dot,hsts trailing,CURLOPT_HSTSand 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.