Two problems of the same shape: information about *why* something failed being replaced by something vaguer. get_dataset_geojson wrapped the JSON parse, the metadata read, the CRS resolution and the canonicalisation in one try and reported all of it as "Stored dataset is not valid JSON" with a 500. An operator whose dataset had an unusable CRS was sent to inspect a file that parses perfectly well, and the canonicaliser's own AppError — with its code and its status — never reached them. Only the parse is now inside that handler; everything after it keeps the error it raised, and a genuine bug becomes a distinct 500 rather than a mislabelled client error. A guard finds the same shape elsewhere: catching Exception around a call into another component and relabelling what it reported. Wrapping one's own private helper stays legitimate and the guard says so. The redirect policy was split without anyone saying so. Two acquisition services rejected every redirect through a hand-rolled opener, while eight allowed a same-origin one through the shared guard — and only the latter checked where the response came from. Both live in the guard now, and the strict path uses the rejecting handler rather than the guard's after-the-fact check: objecting to response.url means urllib already opened the connection and read the body, which for a metadata endpoint is the whole attack. That was a weakening I introduced in this same commit's first draft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6.3 KiB
Python
176 lines
6.3 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, urlopen
|
|
|
|
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
|
|
|
|
|
|
def no_redirect_opener():
|
|
"""An opener that will not follow a redirect anywhere."""
|
|
|
|
return build_opener(_RejectRedirects())
|
|
|
|
|
|
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)
|
|
|
|
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,
|
|
)
|
|
assert_public_http_url(final_url)
|
|
|
|
|
|
def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callable[..., Any]:
|
|
"""An ``urlopen`` replacement that verifies where the response came from.
|
|
|
|
``urlopen`` has already followed the redirect chain by the time it returns,
|
|
so the check is on ``response.url``: the body is still unread, and raising
|
|
here means nothing off-origin is ever parsed or persisted.
|
|
|
|
``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 = urlopen 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
|