Last active
November 26, 2025 12:45
-
-
Save Gunni/b2fa8c72a647f8d61fb685d092e2b046 to your computer and use it in GitHub Desktop.
Useful to make sure HSTS works
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 | |
| import sys | |
| import socket | |
| import ssl | |
| import argparse | |
| from urllib.parse import urlparse, urlunparse | |
| import http.client | |
| # Author: Gunnar Guðvarðarson 2025-11-17 v4 | |
| parser = argparse.ArgumentParser( | |
| prog='verify_redirects', | |
| description='Verify redirects qualify a domain for HSTS properly' | |
| ) | |
| parser.add_argument('domain') | |
| parser.add_argument('--timeout', type=float, default=1) | |
| args = parser.parse_args() | |
| def fail(msg: str, code: int = 1): | |
| print(f"FAIL: {msg}") | |
| sys.exit(code) | |
| def pass_step(name: str): | |
| print(f"PASS: {name}") | |
| def check_tcp_listening(host: str, port: int, timeout: float) -> None: | |
| """Return None on success, raise on failure.""" | |
| with socket.create_connection((host, port), timeout=timeout): | |
| pass | |
| def http_get_raw(host: str, path: str, timeout: float): | |
| """ | |
| Perform a plain HTTP GET using http.client. | |
| Returns (status, headers_dict, final_url_for_context). | |
| """ | |
| conn = http.client.HTTPConnection(host, 80, timeout=timeout) | |
| try: | |
| conn.request("GET", path, headers={"Host": host, "User-Agent": "fsm-check/1.0"}) | |
| resp = conn.getresponse() | |
| status = resp.status | |
| headers = {k.lower(): v for k, v in resp.getheaders()} | |
| # Construct a contextual URL for clarity | |
| context_url = f"http://{host}{path}" | |
| # Read and discard body to cleanly close | |
| _ = resp.read() | |
| return status, headers, context_url | |
| finally: | |
| conn.close() | |
| def https_get_raw(host: str, path: str, timeout: float): | |
| """ | |
| Perform HTTPS GET without following redirects. | |
| Returns (status, headers_dict, final_url_for_context). | |
| Verification uses default CA; for stricter control, adjust as needed. | |
| """ | |
| context = ssl.create_default_context() | |
| conn = http.client.HTTPSConnection(host, 443, timeout=timeout, context=context) | |
| try: | |
| conn.request("GET", path, headers={"Host": host, "User-Agent": "fsm-check/1.0"}) | |
| resp = conn.getresponse() | |
| status = resp.status | |
| headers = {k.lower(): v for k, v in resp.getheaders()} | |
| context_url = f"https://{host}{path}" | |
| _ = resp.read() | |
| return status, headers, context_url | |
| finally: | |
| conn.close() | |
| def normalize_default_ports(parsed): | |
| """ | |
| For comparisons, strip explicit default ports (80 for http, 443 for https). | |
| Returns (scheme, host_no_port, path). | |
| """ | |
| scheme = (parsed.scheme or "").lower() | |
| netloc = parsed.netloc | |
| if ":" in netloc: | |
| host, port = netloc.rsplit(":", 1) | |
| try: | |
| port_int = int(port) | |
| except ValueError: | |
| host = netloc # leave as-is if non-numeric | |
| else: | |
| if (scheme == "http" and port_int == 80) or (scheme == "https" and port_int == 443): | |
| netloc = host # drop default port | |
| return scheme, netloc, parsed.path or "/" | |
| def main(): | |
| # 1) HTTP Listening | |
| try: | |
| check_tcp_listening(args.domain, 80, args.timeout) | |
| except Exception as e: | |
| fail(f"HTTP Listening check failed: could not connect to {args.domain}:80 within {args.timeout}s ({e})") | |
| pass_step("HTTP Listening") | |
| # 2) HTTP Responds with 30X redirect | |
| try: | |
| status, headers, context_url = http_get_raw(args.domain, '/', args.timeout) | |
| if 'strict-transport-security' in headers: | |
| raise ValueError('Strict-Transport-Security header sent over http') | |
| except Exception as e: | |
| fail(f"HTTP 30X check failed: GET {args.domain}/ error: {e}") | |
| if status not in [ 301, 308 ]: | |
| fail(f"HTTP 30X check failed: expected 301/308 but got {status} for {context_url}") | |
| if "location" not in headers: | |
| fail(f"HTTP 30X check failed: missing Location header for {context_url}") | |
| location = headers["location"] | |
| pass_step("HTTP Responds with 30X redirect") | |
| # 3) HTTP 301 Redirect only changes protocol (http -> https), same host and same path | |
| try: | |
| parsed_loc = urlparse(location) | |
| except Exception as e: | |
| fail(f"HTTP 301/308 protocol-only check failed: Destination URL invalid {e}") | |
| # If Location is relative, construct absolute using original host | |
| if not parsed_loc.scheme: | |
| # relative path - assume same host and https scheme per requirement | |
| redirected_url = f"https://{args.domain}/" | |
| parsed_loc = urlparse(redirected_url) | |
| scheme, loc_host, loc_path = normalize_default_ports(parsed_loc) | |
| if scheme != "https": | |
| fail(f"HTTP 301/308 protocol-only check failed: Location scheme must be https; got '{scheme}'") | |
| # Compare host exactly (no rewriting) | |
| if loc_host.lower() != args.domain.lower(): | |
| fail(f"HTTP 301/308 protocol-only check failed: Location host changed from '{args.domain}' to '{loc_host}'") | |
| # Compare path exactly (strict) | |
| orig_path = '/' | |
| if loc_path != orig_path: | |
| fail(f"HTTP 301/308 protocol-only check failed: Location path changed from '{orig_path}' to '{loc_path}'") | |
| pass_step("HTTP 301/308 Redirect only changes protocol") | |
| # 4) HTTPS Listening | |
| try: | |
| check_tcp_listening(args.domain, 443, args.timeout) | |
| except Exception as e: | |
| fail(f"HTTPS Listening check failed: could not connect to {args.domain}:443 within {args.timeout}s ({e})") | |
| pass_step("HTTPS Listening") | |
| # 5) HTTPS Loads or redirects elsewhere (2xx or 3xx is acceptable) | |
| try: | |
| status_https, headers_https, https_context_url = https_get_raw(args.domain, '/', args.timeout) | |
| if 'strict-transport-security' not in headers_https: | |
| raise ValueError('Strict-Transport-Security header missing over https') | |
| except ssl.SSLError as e: | |
| fail(f"HTTPS load check failed: TLS error while requesting {args.domain}/: {e}") | |
| except Exception as e: | |
| fail(f"HTTPS load check failed: GET https://{args.domain}/ error: {e}") | |
| if 200 <= status_https <= 299 or 300 <= status_https <= 399: | |
| pass_step(f"HTTPS Loads or redirects elsewhere") | |
| print("SUCCESS: All checks passed.") | |
| sys.exit(0) | |
| else: | |
| fail(f"HTTPS load check failed: expected 2xx/3xx but got {status_https} for {https_context_url}") | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment