HSTS max-age configured too short, or disabled
ID |
short_hsts_max_age_in_config |
Severity |
low |
Remediation Complexity |
trivial |
Remediation Risk |
medium |
Remediation Effort |
low |
Family |
Cryptography |
Tags |
ASVS50:v3.4.1, CWE:319, configuration, cryptography, network, non-reachable, security |
Description
Strict-Transport-Security tells a browser to reach a site over HTTPS only, and to refuse to let the user
click through a certificate warning. It protects a client that has already seen the header and still
remembers it, so max-age is quite literally the length of the protection.
This detector reports a max-age below one year (31536000 seconds), and calls out 0 separately because
that value disables the header rather than shortening it. The settings it reads are Django’s
SECURE_HSTS_SECONDS and the helmet HSTS configuration in a Node config/ tree.
Only literal numbers are judged. A value computed at start-up or supplied from the environment is left
alone rather than guessed at, which is the same limit the app-config crypto discovery channel reports for
itself in --crypto-coverage.
This is deliberately a separate rule from weak_key_strength_in_config, which reports bcrypt and Argon2
work factors. Both are "a number below a floor", but the weaknesses are different and so are their
mappings: a short max-age is a transport-security gap (CWE-319, ASVS V3.4.1), not a password hash that is
too cheap to compute (CWE-916, ASVS V11.4.4). Sharing one rule meant sharing its tags, and an HSTS finding
was being reported as insufficient password-hashing effort.
Security
The window a short max-age leaves is not theoretical, because it reopens. With max-age=3600, any client
that has not visited in the last hour is back to trusting whatever the network says for its first request —
and that first request is exactly where sslstrip-style downgrades operate: the attacker answers the
plain-HTTP request, serves a proxied copy of the site, and reads the credentials and session cookie the user
submits.
max-age=0 is worse than absent, because it actively instructs a browser that already had an HSTS entry to
forget it. That is the correct way to retire HSTS for a hostname, and a bug anywhere else.
The setting also gates HSTS preloading: the browser preload list requires a max-age of at least one year,
includeSubDomains, and a redirect from HTTP, so a short max-age silently disqualifies a site that
otherwise looks ready.
Mitigation / Fix
Set a year, and only once every hostname the policy covers really serves TLS:
# Django
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
// Node, via helmet
{
"hsts": { "maxAge": 31536000, "includeSubDomains": true, "preload": true }
}
Roll the value up in steps — a few minutes, then a day, then a year — and confirm at each step that every
subdomain serves TLS. An HSTS header cannot be withdrawn from clients that already cached it: if a
subdomain is not ready when includeSubDomains goes out with a long max-age, that subdomain becomes
unreachable for those clients until the max-age expires.
Serve the header only over HTTPS. A browser ignores Strict-Transport-Security on a plain-HTTP response,
so emitting it there gives no protection while looking as though it does.