hosts.py python
91 lines 2.8 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 9 hours ago
1 """Upstream host allowlist (§HGD.6.6)."""
2
3 from __future__ import annotations
4
5 import ipaddress
6 import re
7 from urllib.parse import urlparse
8
9 DEFAULT_ALLOWED_HOSTS = frozenset(
10 {
11 "api.github.com",
12 "raw.githubusercontent.com",
13 }
14 )
15
16 # Optional muse deepen hosts must be explicitly configured (finite list, no wildcards).
17 _HOSTNAME_RE = re.compile(r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$|^[A-Za-z0-9-]+$")
18
19
20 def _is_literal_ip(host: str) -> bool:
21 try:
22 ipaddress.ip_address(host)
23 return True
24 except ValueError:
25 return False
26
27
28 def _is_blocked_special(host: str) -> bool:
29 """Refuse link-local / metadata / loopback IP literals always."""
30 try:
31 addr = ipaddress.ip_address(host)
32 except ValueError:
33 lowered = host.lower()
34 return lowered in {"localhost", "metadata.google.internal"}
35 return bool(
36 addr.is_loopback
37 or addr.is_link_local
38 or addr.is_private
39 or addr.is_reserved
40 or addr.is_multicast
41 or addr.is_unspecified
42 )
43
44
45 def host_allowed(host: str, *, extra_allowed: frozenset[str] | None = None) -> bool:
46 """Return whether ``host`` may be contacted as an upstream.
47
48 Literal IPs and link-local/metadata addresses are always refused.
49 """
50 if not host or not isinstance(host, str):
51 return False
52 hostname = host.strip().lower().split("%", 1)[0]
53 if not hostname:
54 return False
55 if _is_literal_ip(hostname):
56 return False
57 if _is_blocked_special(hostname):
58 return False
59 allowed = DEFAULT_ALLOWED_HOSTS | (extra_allowed or frozenset())
60 return hostname in allowed
61
62
63 def url_host_allowed(url: str, *, extra_allowed: frozenset[str] | None = None) -> bool:
64 """Parse ``url`` and check its hostname against the allowlist."""
65 try:
66 parsed = urlparse(url)
67 except ValueError:
68 return False
69 if parsed.scheme not in {"https", "http"}:
70 return False
71 host = parsed.hostname
72 if host is None:
73 return False
74 return host_allowed(host, extra_allowed=extra_allowed)
75
76
77 def validate_extra_hosts(hosts: list[str]) -> frozenset[str]:
78 """Validate optional muse deepen hostnames (finite, no wildcards, no IPs)."""
79 out: set[str] = set()
80 for raw in hosts:
81 if not isinstance(raw, str) or not raw.strip():
82 raise ValueError("empty muse host")
83 host = raw.strip().lower()
84 if "*" in host or host.startswith("."):
85 raise ValueError(f"wildcard muse host refused: {raw!r}")
86 if _is_literal_ip(host) or _is_blocked_special(host):
87 raise ValueError(f"disallowed muse host: {raw!r}")
88 if not _HOSTNAME_RE.match(host):
89 raise ValueError(f"invalid muse host: {raw!r}")
90 out.add(host)
91 return frozenset(out)
File History 1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 9 hours ago