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.