GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
225 lines
8.2 KiB
Python
225 lines
8.2 KiB
Python
"""Keep bounded acquisition bounded to the official host it was aimed at.
|
|
|
|
Every acquisition service builds its URL from configured settings, so a request
|
|
payload cannot point the runtime somewhere else. The redirect chain can:
|
|
``urlopen`` follows redirects by default, so a misconfigured or compromised
|
|
upstream can send the runtime to the loopback interface, to another container
|
|
on the compose network, or to a cloud metadata endpoint — and whatever comes
|
|
back is then persisted as official source data.
|
|
|
|
That is the substitution the product explicitly forbids, so a redirect that
|
|
leaves the configured origin fails closed instead.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import socket
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
from urllib.request import HTTPRedirectHandler, build_opener
|
|
|
|
from app.core.errors import AppError
|
|
|
|
ALLOWED_SCHEMES = {"http", "https"}
|
|
|
|
|
|
class _RejectRedirects(HTTPRedirectHandler):
|
|
"""Refuse to *follow* a redirect, rather than object after the fact.
|
|
|
|
Checking the final URL means urllib already opened the connection and read
|
|
the response — for a destination like a cloud metadata endpoint that is the
|
|
whole attack. Returning ``None`` here means the request is never made.
|
|
"""
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102
|
|
del req, fp, code, msg, headers, newurl
|
|
return None
|
|
|
|
|
|
class _ValidatedRedirects(HTTPRedirectHandler):
|
|
"""Validate a redirect target before urllib opens the next connection."""
|
|
|
|
def __init__(self, expected_url: str) -> None:
|
|
super().__init__()
|
|
self.expected_url = expected_url
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102
|
|
assert_same_origin_redirect(self.expected_url, newurl)
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
def no_redirect_opener():
|
|
"""An opener that will not follow a redirect anywhere."""
|
|
|
|
return build_opener(_RejectRedirects())
|
|
|
|
|
|
def validated_redirect_opener(expected_url: str):
|
|
"""An opener that validates each redirect before following it."""
|
|
|
|
return build_opener(_ValidatedRedirects(expected_url))
|
|
|
|
|
|
def _reject(code: str, message: str, **details: Any) -> AppError:
|
|
return AppError(code=code, message=message, details=details or None, status_code=502)
|
|
|
|
|
|
def _resolved_addresses(host: str) -> list[str]:
|
|
"""Every address the host resolves to, so a DNS name cannot hide a private one."""
|
|
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
except OSError:
|
|
# Resolution failure is not the guard's problem: the request itself will
|
|
# fail with a clear provider error a moment later.
|
|
return []
|
|
return [str(info[4][0]) for info in infos]
|
|
|
|
|
|
def _is_public_address(value: str) -> bool:
|
|
try:
|
|
address = ipaddress.ip_address(value)
|
|
except ValueError:
|
|
return False
|
|
return not (
|
|
address.is_private
|
|
or address.is_loopback
|
|
or address.is_link_local
|
|
or address.is_reserved
|
|
or address.is_multicast
|
|
or address.is_unspecified
|
|
)
|
|
|
|
|
|
def assert_public_http_url(url: str) -> None:
|
|
"""Refuse anything that is not an ordinary outbound HTTP(S) destination."""
|
|
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ALLOWED_SCHEMES:
|
|
raise _reject(
|
|
"OUTBOUND_URL_NOT_ALLOWED",
|
|
"Bounded acquisition only performs HTTP(S) requests.",
|
|
scheme=parsed.scheme,
|
|
)
|
|
host = parsed.hostname
|
|
if not host:
|
|
raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url)
|
|
if parsed.username is not None or parsed.password is not None:
|
|
raise _reject(
|
|
"OUTBOUND_URL_NOT_ALLOWED",
|
|
"Bounded acquisition refuses credentials embedded in an outbound URL.",
|
|
host=host,
|
|
)
|
|
try:
|
|
parsed.port
|
|
except ValueError as error:
|
|
raise _reject(
|
|
"OUTBOUND_URL_NOT_ALLOWED",
|
|
"Outbound request contains an invalid port.",
|
|
host=host,
|
|
) from error
|
|
|
|
literal = host.strip("[]")
|
|
candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host)
|
|
if candidates and not all(_is_public_address(candidate) for candidate in candidates):
|
|
raise _reject(
|
|
"OUTBOUND_URL_NOT_ALLOWED",
|
|
"Bounded acquisition refuses a private, loopback or link-local destination.",
|
|
host=host,
|
|
)
|
|
|
|
|
|
def _looks_like_ip(value: str) -> bool:
|
|
try:
|
|
ipaddress.ip_address(value)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def assert_same_origin_redirect(original_url: str, final_url: str) -> None:
|
|
"""Allow a redirect only within the origin the request was aimed at.
|
|
|
|
A path change is normal — providers version their endpoints. A host change
|
|
means the bytes no longer come from the source the provenance will claim,
|
|
and a scheme downgrade means they are no longer protected in transit.
|
|
"""
|
|
|
|
if not final_url or final_url == original_url:
|
|
return
|
|
|
|
original = urlparse(original_url)
|
|
final = urlparse(final_url)
|
|
if (final.hostname or "").casefold() != (original.hostname or "").casefold():
|
|
raise _reject(
|
|
"OUTBOUND_REDIRECT_NOT_ALLOWED",
|
|
"The official endpoint redirected to a different host; acquisition fails closed.",
|
|
expected_host=original.hostname,
|
|
redirect_host=final.hostname,
|
|
)
|
|
if original.scheme == "https" and final.scheme != "https":
|
|
raise _reject(
|
|
"OUTBOUND_REDIRECT_NOT_ALLOWED",
|
|
"The official endpoint redirected from HTTPS to an unprotected scheme.",
|
|
redirect_scheme=final.scheme,
|
|
)
|
|
# This catches embedded credentials, invalid ports and non-public
|
|
# resolutions before the redirect handler can construct the next request.
|
|
assert_public_http_url(final_url)
|
|
original_port = original.port or (443 if original.scheme == "https" else 80)
|
|
final_port = final.port or (443 if final.scheme == "https" else 80)
|
|
same_scheme_port = final.scheme == original.scheme and final_port == original_port
|
|
safe_https_upgrade = original.scheme == "http" and final.scheme == "https" and final_port == 443
|
|
if not (same_scheme_port or safe_https_upgrade):
|
|
raise _reject(
|
|
"OUTBOUND_REDIRECT_NOT_ALLOWED",
|
|
"The official endpoint redirected to a different network origin.",
|
|
expected_port=original_port,
|
|
redirect_port=final_port,
|
|
)
|
|
|
|
|
|
def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callable[..., Any]:
|
|
"""An ``urlopen`` replacement that keeps redirects on the expected origin.
|
|
|
|
Redirect targets are validated by the handler *before* urllib opens the
|
|
next connection. The final response URL is checked again as a defensive
|
|
invariant for injected/custom transports.
|
|
|
|
``allow_redirect=False`` refuses any redirect at all, which is what the
|
|
paged OGC feature readers want: a page URL they built themselves should be
|
|
answered by that URL, and a redirect there means the endpoint moved under
|
|
them mid-pagination.
|
|
"""
|
|
|
|
assert_public_http_url(expected_url)
|
|
|
|
default_transport = (
|
|
validated_redirect_opener(expected_url).open
|
|
if allow_redirect
|
|
else no_redirect_opener().open
|
|
)
|
|
|
|
def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
|
|
response = (_transport or default_transport)(request, *args, **kwargs)
|
|
final_url = str(getattr(response, "url", "") or "")
|
|
try:
|
|
if not allow_redirect and final_url and final_url != expected_url:
|
|
raise _reject(
|
|
"OUTBOUND_REDIRECT_NOT_ALLOWED",
|
|
"The official endpoint redirected; this reader accepts only the URL it requested.",
|
|
expected_url=expected_url,
|
|
redirect_url=final_url,
|
|
)
|
|
assert_same_origin_redirect(expected_url, final_url)
|
|
except AppError:
|
|
close = getattr(response, "close", None)
|
|
if callable(close):
|
|
close()
|
|
raise
|
|
return response
|
|
|
|
return _open
|