keep a precise failure precise, and state one redirect policy

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>
This commit is contained in:
Jens
2026-08-22 23:35:59 +02:00
co-authored by Claude Opus 5
parent c6837ec1b2
commit 7c052a339e
8 changed files with 375 additions and 26 deletions
+21 -1
View File
@@ -2906,8 +2906,19 @@ class DatasetService:
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
raw = load_dataset_text(dataset.storage_path)
# Only the parse can be "not valid JSON". Everything after it fails for
# its own reasons and must say so, or an operator is sent to inspect a
# file that parses perfectly well.
try:
payload = json.loads(raw)
except Exception as exc:
raise AppError(
code="INVALID_GEOJSON",
message="Stored dataset is not valid JSON",
status_code=500,
) from exc
try:
metadata_value = getattr(dataset, "metadata_json", None)
metadata = metadata_value if isinstance(metadata_value, dict) else {}
provenance_value = getattr(dataset, "provenance_metadata", None)
@@ -2933,8 +2944,17 @@ class DatasetService:
)
)
return VectorFeatureService.canonicalize_geojson_payload(payload, source_crs=str(source_crs))
except AppError:
# The canonicaliser's diagnosis is more precise than anything this
# layer could substitute for it.
raise
except Exception as exc:
raise AppError(code="INVALID_GEOJSON", message="Stored dataset is not valid JSON", status_code=500) from exc
raise AppError(
code="DATASET_GEOJSON_UNREADABLE",
message="The stored dataset could not be read as canonical GeoJSON",
details={"dataset_id": str(dataset.id), "error_type": type(exc).__name__},
status_code=500,
) from exc
@staticmethod
def inspect_vector_dataset(db: Session, dataset_id: UUID) -> dict[str, Any]:
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
from urllib.request import HTTPRedirectHandler, Request, build_opener
from urllib.request import Request
from uuid import UUID
from geoalchemy2.shape import to_shape
@@ -20,20 +20,12 @@ from shapely.validation import make_valid
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.services.outbound_request_guard import guarded_opener
from app.models import Area, Dataset, Project
from app.schemas.grb import GrbAcquireRequest, GrbAcquisitionResult, GrbProductRead
from app.services.dataset_service import DatasetService
class _RejectRedirects(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
del req, fp, code, msg, headers, newurl
return None
_NO_REDIRECT_OPENER = build_opener(_RejectRedirects())
@dataclass(frozen=True)
class GrbCollection:
name: str
@@ -368,7 +360,7 @@ class GrbAcquisitionService:
},
)
try:
with (opener or _NO_REDIRECT_OPENER.open)(
with (opener or guarded_opener(url, allow_redirect=False))(
request,
timeout=settings.grb_timeout_seconds,
) as response:
@@ -10,7 +10,7 @@ import re
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlparse
from urllib.request import HTTPRedirectHandler, Request, build_opener
from urllib.request import Request
from uuid import UUID
from geoalchemy2.shape import to_shape
@@ -21,6 +21,7 @@ from shapely.validation import make_valid
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.services.outbound_request_guard import guarded_opener
from app.models import Area, Dataset, Project
from app.schemas.official_vector import (
OfficialVectorAcquireRequest,
@@ -30,13 +31,6 @@ from app.schemas.official_vector import (
from app.services.dataset_service import DatasetService
class _RejectRedirects(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
del req, fp, code, msg, headers, newurl
return None
_NO_REDIRECT_OPENER = build_opener(_RejectRedirects())
_TO_LAMBERT72 = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
_TO_WGS84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
@@ -1086,7 +1080,7 @@ class OfficialVectorAcquisitionService:
},
)
try:
with (opener or _NO_REDIRECT_OPENER.open)(
with (opener or guarded_opener(url, allow_redirect=False))(
request,
timeout=settings.official_vector_timeout_seconds,
) as response:
+36 -3
View File
@@ -18,13 +18,32 @@ import socket
from collections.abc import Callable
from typing import Any
from urllib.parse import urlparse
from urllib.request import urlopen
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)
@@ -117,20 +136,34 @@ def assert_same_origin_redirect(original_url: str, final_url: str) -> None:
assert_public_http_url(final_url)
def guarded_opener(expected_url: str) -> Callable[..., Any]:
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 urlopen)(request, *args, **kwargs)
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)