mirror of
https://github.com/wilsonfreitas/awesome-quant.git
synced 2026-11-02 12:26:33 +00:00
419 lines
14 KiB
Python
419 lines
14 KiB
Python
"""Safe, retrying HTTP URL classification for README audit tooling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.client
|
|
import ipaddress
|
|
import socket
|
|
import ssl
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from time import sleep
|
|
from typing import Callable
|
|
from urllib.parse import SplitResult, urljoin, urlsplit, urlunsplit
|
|
|
|
|
|
USER_AGENT = "awesome-quant-url-audit/1.0"
|
|
RETRYABLE_STATUSES = {408, 425, 500, 502, 503, 504}
|
|
REDIRECT_STATUSES = {300, 301, 302, 303, 307, 308}
|
|
PERMANENT_REDIRECT_STATUSES = {301, 308}
|
|
TRANSPORT_ERRORS = (OSError, ssl.SSLError, socket.gaierror, TimeoutError)
|
|
NAT64_WELL_KNOWN_NETWORK = ipaddress.IPv6Network("64:ff9b::/96")
|
|
|
|
|
|
class Outcome(StrEnum):
|
|
OK = "ok"
|
|
DEAD = "dead"
|
|
RESTRICTED = "restricted"
|
|
TRANSIENT = "transient"
|
|
HTTP_ERROR = "http_error"
|
|
PERMANENT_REDIRECT = "permanent_redirect"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RawResponse:
|
|
status: int
|
|
location: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UrlObservation:
|
|
requested_url: str
|
|
final_url: str
|
|
status: int | None
|
|
outcome: Outcome
|
|
attempts: int
|
|
redirect_chain: tuple[str, ...] = ()
|
|
error: str = ""
|
|
|
|
|
|
class UnsafeUrlError(ValueError):
|
|
"""Raised before connecting to a URL that is not publicly routable."""
|
|
|
|
|
|
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
"""HTTPS connection whose TCP peer is pinned while SNI remains the hostname."""
|
|
|
|
def __init__(self, host: str, port: int, server_hostname: str, timeout: float):
|
|
super().__init__(host, port, timeout=timeout, context=ssl.create_default_context())
|
|
self._server_hostname = server_hostname
|
|
|
|
def connect(self) -> None:
|
|
self.sock = self._create_connection( # type: ignore[attr-defined]
|
|
(self.host, self.port),
|
|
self.timeout,
|
|
self.source_address, # type: ignore[attr-defined]
|
|
)
|
|
if self._tunnel_host: # type: ignore[attr-defined]
|
|
self._tunnel() # type: ignore[attr-defined]
|
|
self.sock = self._context.wrap_socket( # type: ignore[attr-defined]
|
|
self.sock, server_hostname=self._server_hostname
|
|
)
|
|
|
|
|
|
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
|
"""HTTP connection using an already-validated address as its TCP peer."""
|
|
|
|
|
|
def _embedded_ipv4_addresses(
|
|
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
) -> tuple[ipaddress.IPv4Address, ...]:
|
|
if not isinstance(address, ipaddress.IPv6Address):
|
|
return ()
|
|
|
|
embedded = [
|
|
candidate
|
|
for candidate in (address.ipv4_mapped, address.sixtofour)
|
|
if candidate is not None
|
|
]
|
|
if address.teredo is not None:
|
|
embedded.extend(address.teredo)
|
|
if address in NAT64_WELL_KNOWN_NETWORK:
|
|
embedded.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
|
|
return tuple(embedded)
|
|
|
|
|
|
def _is_public_address(
|
|
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
) -> bool:
|
|
embedded_addresses_are_public = all(
|
|
embedded.is_global and not embedded.is_multicast
|
|
for embedded in _embedded_ipv4_addresses(address)
|
|
)
|
|
return (
|
|
address.is_global
|
|
and not address.is_multicast
|
|
and embedded_addresses_are_public
|
|
)
|
|
|
|
|
|
def validate_public_url(url: str) -> tuple[str, int, str]:
|
|
"""Validate an HTTP(S) URL and return hostname, effective port, and pinned IP."""
|
|
try:
|
|
parsed = urlsplit(url)
|
|
except ValueError as error:
|
|
raise UnsafeUrlError("invalid URL") from error
|
|
|
|
scheme = parsed.scheme.lower()
|
|
if scheme not in {"http", "https"}:
|
|
raise UnsafeUrlError("unsupported URL scheme")
|
|
if parsed.username is not None or parsed.password is not None:
|
|
raise UnsafeUrlError("URL credentials are not allowed")
|
|
|
|
try:
|
|
hostname = parsed.hostname
|
|
explicit_port = parsed.port
|
|
except ValueError as error:
|
|
raise UnsafeUrlError("invalid port") from error
|
|
if not hostname:
|
|
raise UnsafeUrlError("missing hostname")
|
|
if explicit_port == 0:
|
|
raise UnsafeUrlError("port zero is not allowed")
|
|
|
|
port = explicit_port or (443 if scheme == "https" else 80)
|
|
results = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
|
|
|
|
addresses: list[str] = []
|
|
for _family, _socktype, _protocol, _canonname, sockaddr in results:
|
|
try:
|
|
address = ipaddress.ip_address(sockaddr[0])
|
|
except (IndexError, ValueError) as error:
|
|
raise UnsafeUrlError("invalid resolved address") from error
|
|
if not _is_public_address(address):
|
|
raise UnsafeUrlError("resolved address is not globally routable")
|
|
addresses.append(str(address))
|
|
if not addresses:
|
|
raise UnsafeUrlError("hostname did not resolve")
|
|
|
|
return hostname, port, min(addresses)
|
|
|
|
|
|
def _host_header(hostname: str, port: int, scheme: str) -> str:
|
|
bracketed_host = f"[{hostname}]" if ":" in hostname else hostname
|
|
default_port = 443 if scheme == "https" else 80
|
|
return bracketed_host if port == default_port else f"{bracketed_host}:{port}"
|
|
|
|
|
|
def _request_target(parsed: SplitResult) -> str:
|
|
target = parsed.path or "/"
|
|
return f"{target}?{parsed.query}" if parsed.query else target
|
|
|
|
|
|
def _single_request(
|
|
method: str,
|
|
parsed: SplitResult,
|
|
hostname: str,
|
|
port: int,
|
|
address: str,
|
|
timeout: float,
|
|
) -> RawResponse:
|
|
connection: http.client.HTTPConnection
|
|
if parsed.scheme.lower() == "https":
|
|
connection = _PinnedHTTPSConnection(address, port, hostname, timeout)
|
|
else:
|
|
connection = _PinnedHTTPConnection(address, port, timeout=timeout)
|
|
|
|
response = None
|
|
try:
|
|
connection.request(
|
|
method,
|
|
_request_target(parsed),
|
|
headers={
|
|
"Host": _host_header(hostname, port, parsed.scheme.lower()),
|
|
"User-Agent": USER_AGENT,
|
|
},
|
|
)
|
|
response = connection.getresponse()
|
|
return RawResponse(response.status, response.getheader("Location"))
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
connection.close()
|
|
|
|
|
|
def request_once(url: str, timeout: float = 10) -> RawResponse:
|
|
"""Perform a pinned HEAD request, falling back to GET when HEAD is unsupported."""
|
|
hostname, port, address = validate_public_url(url)
|
|
parsed = urlsplit(url)
|
|
response = _single_request("HEAD", parsed, hostname, port, address, timeout)
|
|
if response.status in {405, 501}:
|
|
response = _single_request("GET", parsed, hostname, port, address, timeout)
|
|
return response
|
|
|
|
|
|
def _canonical_url(url: str) -> str:
|
|
parsed = urlsplit(url)
|
|
scheme = parsed.scheme.lower()
|
|
hostname = (parsed.hostname or "").lower()
|
|
port = parsed.port
|
|
default_port = 443 if scheme == "https" else 80
|
|
host = f"[{hostname}]" if ":" in hostname else hostname
|
|
netloc = host if port in {None, default_port} else f"{host}:{port}"
|
|
path = parsed.path.rstrip("/")
|
|
return urlunsplit((scheme, netloc, path, parsed.query, ""))
|
|
|
|
|
|
def _http_error(
|
|
requested_url: str,
|
|
final_url: str,
|
|
status: int | None,
|
|
attempts: int,
|
|
redirects: list[str],
|
|
error: str,
|
|
) -> UrlObservation:
|
|
return UrlObservation(
|
|
requested_url,
|
|
final_url,
|
|
status,
|
|
Outcome.HTTP_ERROR,
|
|
attempts,
|
|
tuple(redirects),
|
|
error,
|
|
)
|
|
|
|
|
|
def _classify_terminal(
|
|
requested_url: str,
|
|
final_url: str,
|
|
response: RawResponse,
|
|
attempts: int,
|
|
redirects: list[str],
|
|
saw_meaningful_permanent_redirect: bool,
|
|
) -> UrlObservation:
|
|
if response.status in {404, 410}:
|
|
outcome = Outcome.DEAD
|
|
error = ""
|
|
elif response.status in {401, 403, 407, 429, 451}:
|
|
outcome = Outcome.RESTRICTED
|
|
error = ""
|
|
elif 200 <= response.status <= 399:
|
|
outcome = (
|
|
Outcome.PERMANENT_REDIRECT
|
|
if saw_meaningful_permanent_redirect
|
|
else Outcome.OK
|
|
)
|
|
error = ""
|
|
else:
|
|
outcome = Outcome.HTTP_ERROR
|
|
error = f"HTTP status {response.status}"
|
|
return UrlObservation(
|
|
requested_url,
|
|
final_url,
|
|
response.status,
|
|
outcome,
|
|
attempts,
|
|
tuple(redirects),
|
|
error,
|
|
)
|
|
|
|
|
|
def probe_url(
|
|
requested_url: str,
|
|
*,
|
|
requester: Callable[[str, float], RawResponse] = request_once,
|
|
sleeper: Callable[[float], None] = sleep,
|
|
timeout: float = 10,
|
|
) -> UrlObservation:
|
|
"""Classify a public HTTP(S) URL without allowing DNS rebinding requests."""
|
|
last_transport_error = ""
|
|
for attempt, delay in enumerate((0, 1, 2), start=1):
|
|
sleeper(delay)
|
|
current_url = requested_url
|
|
redirects: list[str] = []
|
|
seen: set[str] = set()
|
|
saw_meaningful_permanent_redirect = False
|
|
try:
|
|
while True:
|
|
try:
|
|
validate_public_url(current_url)
|
|
except UnsafeUrlError as error:
|
|
prefix = "unsafe URL" if current_url == requested_url else "unsafe redirect target"
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
None,
|
|
attempt,
|
|
redirects,
|
|
f"{prefix}: {error}",
|
|
)
|
|
|
|
current_canonical = _canonical_url(current_url)
|
|
seen.add(current_canonical)
|
|
|
|
try:
|
|
response = requester(current_url, timeout)
|
|
except http.client.InvalidURL:
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
None,
|
|
attempt,
|
|
redirects,
|
|
"invalid request URL",
|
|
)
|
|
if response.status not in REDIRECT_STATUSES:
|
|
if response.status in RETRYABLE_STATUSES:
|
|
if attempt == 3:
|
|
return UrlObservation(
|
|
requested_url,
|
|
current_url,
|
|
response.status,
|
|
Outcome.TRANSIENT,
|
|
attempt,
|
|
tuple(redirects),
|
|
f"retry limit exceeded: HTTP {response.status}",
|
|
)
|
|
break
|
|
return _classify_terminal(
|
|
requested_url,
|
|
current_url,
|
|
response,
|
|
attempt,
|
|
redirects,
|
|
saw_meaningful_permanent_redirect,
|
|
)
|
|
|
|
if not response.location:
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
response.status,
|
|
attempt,
|
|
redirects,
|
|
"missing redirect location",
|
|
)
|
|
if len(redirects) >= 5:
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
response.status,
|
|
attempt,
|
|
redirects,
|
|
"redirect limit exceeded",
|
|
)
|
|
|
|
try:
|
|
target_url = urljoin(current_url, response.location)
|
|
except ValueError:
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
response.status,
|
|
attempt,
|
|
redirects,
|
|
"invalid redirect location",
|
|
)
|
|
try:
|
|
validate_public_url(target_url)
|
|
except UnsafeUrlError as error:
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
response.status,
|
|
attempt,
|
|
redirects,
|
|
f"unsafe redirect target: {error}",
|
|
)
|
|
target_canonical = _canonical_url(target_url)
|
|
if target_canonical in seen:
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
response.status,
|
|
attempt,
|
|
redirects,
|
|
"redirect loop",
|
|
)
|
|
if (
|
|
response.status in PERMANENT_REDIRECT_STATUSES
|
|
and target_canonical != current_canonical
|
|
):
|
|
saw_meaningful_permanent_redirect = True
|
|
redirects.append(target_url)
|
|
seen.add(target_canonical)
|
|
current_url = target_url
|
|
except TRANSPORT_ERRORS as error:
|
|
last_transport_error = type(error).__name__
|
|
if attempt == 3:
|
|
return UrlObservation(
|
|
requested_url,
|
|
requested_url,
|
|
None,
|
|
Outcome.TRANSIENT,
|
|
attempt,
|
|
(),
|
|
f"transport error: {last_transport_error}",
|
|
)
|
|
except UnsafeUrlError as error:
|
|
prefix = "unsafe URL" if current_url == requested_url else "unsafe redirect target"
|
|
return _http_error(
|
|
requested_url,
|
|
current_url,
|
|
None,
|
|
attempt,
|
|
redirects,
|
|
f"{prefix}: {error}",
|
|
)
|
|
|
|
raise AssertionError("retry loop exhausted unexpectedly")
|