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
|