Skip to content

Instantly share code, notes, and snippets.

@Gunni
Last active November 26, 2025 12:45
Show Gist options
  • Select an option

  • Save Gunni/b2fa8c72a647f8d61fb685d092e2b046 to your computer and use it in GitHub Desktop.

Select an option

Save Gunni/b2fa8c72a647f8d61fb685d092e2b046 to your computer and use it in GitHub Desktop.
Useful to make sure HSTS works
#!/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