Skip to content

Instantly share code, notes, and snippets.

@jmvrbanac
Last active April 24, 2026 18:41
Show Gist options
  • Select an option

  • Save jmvrbanac/f3fb793d6bf0c24d9ad106df4c710724 to your computer and use it in GitHub Desktop.

Select an option

Save jmvrbanac/f3fb793d6bf0c24d9ad106df4c710724 to your computer and use it in GitHub Desktop.
Check HSTS Across a set of websites
#!/usr/bin/env python3
# Copyright (c) 2026 John Vrbanac <john.vrbanac@linux.com>
# Licensed under the MIT License. See LICENSE for details.
"""Check HSTS (HTTP Strict Transport Security) configuration for a website."""
import sys
import urllib.request
import urllib.error
from dataclasses import dataclass, field
from urllib.parse import urlparse
MIN_MAX_AGE = 31_536_000 # 1 year, OWASP minimum
@dataclass
class HSTSResult:
hostname: str
https_reachable: bool = False
http_redirects_to_https: bool = False
hsts_header_present: bool = False
max_age: int | None = None
includes_subdomains: bool = False
preload: bool = False
issues: list[str] = field(default_factory=list)
@property
def passed(self) -> bool:
return (
self.https_reachable
and self.hsts_header_present
and self.http_redirects_to_https
and not self.issues
)
def _apply_hsts_header(self, header: str) -> None:
for directive in (d.strip().lower() for d in header.split(";")):
if directive.startswith("max-age="):
try:
self.max_age = int(directive.split("=", 1)[1])
if self.max_age == 0:
self.issues.append("max-age=0 revokes HSTS")
elif self.max_age < MIN_MAX_AGE:
self.issues.append(
f"max-age={self.max_age} is below recommended minimum ({MIN_MAX_AGE})"
)
except ValueError:
self.issues.append("Invalid max-age value")
elif directive == "includesubdomains":
self.includes_subdomains = True
elif directive == "preload":
self.preload = True
if self.max_age is None:
self.issues.append("HSTS header missing max-age directive")
if not self.includes_subdomains:
self.issues.append("Missing includeSubDomains directive (required by OWASP)")
def check_hsts(hostname: str) -> HSTSResult:
result = HSTSResult(hostname=hostname)
https_url = f"https://{hostname}"
http_url = f"http://{hostname}"
try:
req = urllib.request.Request(https_url, method="HEAD")
req.add_header("User-Agent", "hsts-checker/1.0")
with urllib.request.urlopen(req, timeout=10) as resp:
result.https_reachable = True
all_hsts = resp.headers.get_all("Strict-Transport-Security") or []
if not all_hsts:
result.issues.append("Missing Strict-Transport-Security header")
elif len(all_hsts) > 1:
result.issues.append(
f"Multiple Strict-Transport-Security headers found ({len(all_hsts)}); "
"exactly one is required for preload eligibility"
)
else:
result.hsts_header_present = True
result._apply_hsts_header(all_hsts[0])
except urllib.error.URLError as e:
result.issues.append(f"HTTPS unreachable: {e.reason}")
return result
except Exception as e:
result.issues.append(f"HTTPS error: {e}")
return result
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
try:
req = urllib.request.Request(http_url, method="HEAD")
req.add_header("User-Agent", "hsts-checker/1.0")
with urllib.request.build_opener(NoRedirect()).open(req, timeout=10):
result.issues.append("HTTP does not redirect to HTTPS (returned 200)")
except urllib.error.HTTPError as e:
if e.code in (301, 302, 307, 308):
location = e.headers.get("Location", "")
parsed = urlparse(location)
if parsed.scheme != "https":
result.issues.append(f"HTTP redirects to non-HTTPS location: {location}")
elif parsed.hostname != hostname:
result.issues.append(
f"HTTP redirects to '{parsed.hostname}' instead of root domain '{hostname}'; "
"clients will not learn that the root domain requires HTTPS"
)
else:
result.http_redirects_to_https = True
if e.headers.get("Strict-Transport-Security"):
result.issues.append(
"HSTS header present on HTTP response; browsers ignore it and it signals misconfiguration"
)
else:
result.issues.append(f"Unexpected HTTP status: {e.code}")
except Exception as e:
result.issues.append(f"HTTP check error: {e}")
return result
def print_report(r: HSTSResult) -> None:
ok = "\033[32mOK\033[0m"
fail = "\033[31mFAIL\033[0m"
warn = "\033[33mWARN\033[0m"
def status(cond: bool) -> str:
return ok if cond else fail
print(f"\nHSTS Report: {r.hostname}")
print("=" * 50)
print(f" HTTPS reachable {status(r.https_reachable)}")
print(f" HTTP -> HTTPS (root) {status(r.http_redirects_to_https)}")
print(f" HSTS header (exactly 1) {status(r.hsts_header_present)}")
if r.max_age is not None:
print(f" max-age {r.max_age}s {status(r.max_age >= MIN_MAX_AGE)}")
else:
print(f" max-age (none) {fail}")
print(f" includeSubDomains {status(r.includes_subdomains)}")
print(f" preload {ok if r.preload else warn}")
if r.issues:
print("\nIssues:")
for issue in r.issues:
print(f" - {issue}")
print(f"\nOverall: {status(r.passed)}\n")
def main() -> None:
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <hostname> [hostname ...]")
print(f"Example: {sys.argv[0]} example.com")
sys.exit(1)
exit_code = 0
for hostname in sys.argv[1:]:
hostname = hostname.strip().removeprefix("https://").removeprefix("http://").rstrip("/")
result = check_hsts(hostname)
print_report(result)
if not result.passed:
exit_code = 1
sys.exit(exit_code)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment