"""Safety rail: compliance scenarios may only target loopback mock servers. Fail closed: anything that is not a loopback address is refused, and the known live Gitea instances are refused by name. There is deliberately no environment override — requirement 6 of issue #156 forbids credentialed destructive scenarios against live or production-like hosts. """ import ipaddress from urllib.parse import urlsplit LIVE_GITEA_HOSTS = frozenset({"gitea.dadeschools.net", "gitea.prgs.cc"}) _LOOPBACK_NAMES = frozenset({"localhost"}) class UnsafeComplianceTargetError(Exception): """Raised when a compliance scenario targets a non-loopback host.""" def _hostname(target): """Extract a lowercase hostname from a URL or bare host[:port] string.""" if "://" in target: return (urlsplit(target).hostname or "").lower() host = target.strip() # Bracketed IPv6, optionally with a port: [::1] or [::1]:8080. if host.startswith("["): return host.split("]", 1)[0][1:].lower() # Exactly one colon means host:port; more means a bare IPv6 literal. if host.count(":") == 1: return host.rsplit(":", 1)[0].lower() return host.lower() def _is_loopback(host): """True only for real loopback IPs (127.0.0.0/8, ::1) or 'localhost'. A string-prefix check like startswith('127.') would accept DNS names such as 127.0.0.1.evil.com — the host must parse as an IP address. """ if host in _LOOPBACK_NAMES: return True try: return ipaddress.ip_address(host).is_loopback except ValueError: return False def is_safe_compliance_target(target): """Return (ok, reason). Only loopback targets are safe.""" host = _hostname(str(target)) if not host: return False, "empty target host; fail closed" if host in LIVE_GITEA_HOSTS: return False, ( f"'{host}' is a live Gitea instance; compliance scenarios must " "never run credentialed against live hosts" ) if _is_loopback(host): return True, "loopback target" return False, ( f"'{host}' is not a loopback address; compliance scenarios must " "target a local mock Gitea server" ) def assert_safe_compliance_target(target): """Raise UnsafeComplianceTargetError unless *target* is loopback.""" ok, reason = is_safe_compliance_target(target) if not ok: raise UnsafeComplianceTargetError(reason)