Last active
April 24, 2026 18:41
-
-
Save jmvrbanac/f3fb793d6bf0c24d9ad106df4c710724 to your computer and use it in GitHub Desktop.
Check HSTS Across a set of websites
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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