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
@@ -0,0 +1,125 @@
"""A precise failure must not be reported as a generic one.
``get_dataset_geojson`` wrapped the JSON parse, the metadata read, the CRS
resolution and the canonicalisation in one ``try``, and reported everything as
"Stored dataset is not valid JSON" with a 500. An operator whose dataset has an
unusable CRS was sent to inspect a file that parses perfectly well, and the
specific AppError the canonicaliser raised — with its own code and status —
never reached them.
"""
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import pytest
from app.core.errors import AppError
from app.models import Dataset
from app.services.dataset_service import DatasetService
from app.services.vector_feature_service import VectorFeatureService
class FakeSession:
def __init__(self, dataset: Dataset) -> None:
self.dataset = dataset
def get(self, _model, item_id):
return self.dataset if item_id == self.dataset.id else None
def _dataset(tmp_path: Path, payload: str) -> Dataset:
path = tmp_path / "features.geojson"
path.write_text(payload, encoding="utf-8")
return Dataset(
id=uuid4(),
project_id=uuid4(),
name="features.geojson",
dataset_type="geojson",
source="test",
status="ready",
storage_path=str(path),
crs="EPSG:4326",
)
def test_a_valid_dataset_is_returned(tmp_path: Path) -> None:
dataset = _dataset(
tmp_path,
json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [5.0, 51.0]},
"properties": {},
}
],
}
),
)
result = DatasetService.get_dataset_geojson(FakeSession(dataset), dataset.id)
assert result["type"] == "FeatureCollection"
def test_unparseable_bytes_are_reported_as_invalid_json(tmp_path: Path) -> None:
dataset = _dataset(tmp_path, "{ not json")
with pytest.raises(AppError) as exc_info:
DatasetService.get_dataset_geojson(FakeSession(dataset), dataset.id)
assert exc_info.value.code == "INVALID_GEOJSON"
assert "JSON" in exc_info.value.message
def test_a_parseable_file_that_is_not_a_feature_collection_says_so(tmp_path: Path) -> None:
"""Valid JSON, wrong shape. Telling the operator it is not JSON sends them
to inspect a file that parses perfectly well."""
dataset = _dataset(tmp_path, json.dumps({"type": "Feature", "geometry": None}))
with pytest.raises(AppError) as exc_info:
DatasetService.get_dataset_geojson(FakeSession(dataset), dataset.id)
assert exc_info.value.code == "INVALID_GEOJSON"
assert "FeatureCollection" in exc_info.value.message
# The canonicaliser's own status survives; it is a bad request, not a
# server fault.
assert exc_info.value.status_code == 400
def test_a_canonicalisation_failure_keeps_its_own_code(tmp_path: Path, monkeypatch) -> None:
dataset = _dataset(tmp_path, json.dumps({"type": "FeatureCollection", "features": []}))
def failing(*_args, **_kwargs):
raise AppError(code="INVALID_CRS", message="Unusable source CRS", status_code=409)
monkeypatch.setattr(VectorFeatureService, "canonicalize_geojson_payload", staticmethod(failing))
with pytest.raises(AppError) as exc_info:
DatasetService.get_dataset_geojson(FakeSession(dataset), dataset.id)
assert exc_info.value.code == "INVALID_CRS"
assert exc_info.value.status_code == 409
def test_an_unexpected_failure_still_becomes_a_server_error(tmp_path: Path, monkeypatch) -> None:
"""A genuine bug must not masquerade as a client mistake."""
dataset = _dataset(tmp_path, json.dumps({"type": "FeatureCollection", "features": []}))
def exploding(*_args, **_kwargs):
raise RuntimeError("pyproj blew up")
monkeypatch.setattr(VectorFeatureService, "canonicalize_geojson_payload", staticmethod(exploding))
with pytest.raises(AppError) as exc_info:
DatasetService.get_dataset_geojson(FakeSession(dataset), dataset.id)
assert exc_info.value.status_code == 500
assert exc_info.value.code == "DATASET_GEOJSON_UNREADABLE"
@@ -0,0 +1,88 @@
"""A broad handler must not overwrite a precise diagnosis with a generic one.
``except Exception: raise AppError(...)`` also catches ``AppError``, so a
service that raised INVALID_CRS with a 409 comes out as whatever generic code
the outer handler chose. For a product whose whole claim is that a result can
be traced back to its cause, that is the wrong direction in which to lose
information.
Wrapping a narrow call whose failure the outer message describes better is
legitimate — coercing a CRS string, parsing one geometry, calling one's own
private helper. Relabelling what *another* component reported is not.
"""
from __future__ import annotations
import re
from pathlib import Path
APP = Path(__file__).resolve().parents[1] / "app"
GENERIC_HANDLER = re.compile(r"except Exception as \w+:\s*\n\s*raise AppError\(", re.M)
OTHER_SERVICE = re.compile(r"\b([A-Z]\w*Service)\.")
# Cross-component entry points that carry their own considered diagnosis.
GOVERNED_CALLS = re.compile(r"assert_eligible|assert_within_storage_root|canonicalize_|_load_dataset_payload")
def _enclosing_service(text: str, handler_line: int) -> str | None:
"""The class the handler sits in, not merely the first one in the file."""
enclosing = None
for match in re.finditer(r"^class (\w+)[:(]", text, re.M):
if text[: match.start()].count(chr(10)) > handler_line:
break
enclosing = match.group(1)
return enclosing
def _try_body(lines: list[str], handler_line: int) -> str:
index = handler_line
while index > 0 and lines[index].strip() != "try:":
index -= 1
return "\n".join(lines[index:handler_line])
def calls_another_component(body: str, own_service: str | None) -> bool:
if GOVERNED_CALLS.search(body):
return True
return any(name != own_service for name in OTHER_SERVICE.findall(body))
def _offenders() -> list[str]:
found: list[str] = []
for path in sorted(APP.rglob("*.py")):
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
for match in GENERIC_HANDLER.finditer(text):
preceding = text[max(0, match.start() - 240):match.start() + 40]
if "except AppError:" in preceding:
continue
handler_line = text[:match.start()].count("\n")
own = _enclosing_service(text, handler_line)
if calls_another_component(_try_body(lines, handler_line), own):
found.append(f"{path.relative_to(APP)}:{handler_line + 1}")
return found
def test_a_call_into_another_component_keeps_the_error_it_raised() -> None:
offenders = _offenders()
assert not offenders, (
"These catch Exception around a call into another component and relabel "
"whatever it raised, including its AppError. Add `except AppError: "
f"raise` before the generic handler: {offenders}"
)
def test_the_check_recognises_the_shape_it_guards_against() -> None:
"""Without this the guard could pass because its pattern never matches."""
other = "try:\n DatasetService.get_dataset(db, dataset_id)"
own = "try:\n GrbAcquisitionService._extract_dimension(geometry)"
governed = "try:\n DatasetConsumptionGate.assert_eligible(dataset)"
assert calls_another_component(other, "GrbAcquisitionService")
assert calls_another_component(governed, "GrbAcquisitionService")
# Wrapping one's own private helper is the legitimate case.
assert not calls_another_component(own, "GrbAcquisitionService")
assert GENERIC_HANDLER.search("except Exception as exc:\n raise AppError(")
@@ -162,3 +162,96 @@ class TestTheGuardIsWiredIntoAcquisition:
OrthophotoAcquisitionService._fetch("http://192.168.10.150/wms", self._settings())
assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
class TestOneRedirectPolicy:
"""Two acquisition services rejected every redirect through their own
opener while eight allowed a same-origin one through this guard. Two
policies with no stated reason, and only one of them checked where the
response actually came from."""
def test_the_strict_policy_refuses_any_redirect(self) -> None:
from app.services.outbound_request_guard import guarded_opener
opener = guarded_opener("https://geo.api.vlaanderen.be/GRB/wfs", allow_redirect=False)
class _Redirected:
url = "https://geo.api.vlaanderen.be/GRB/wfs/v2"
def __enter__(self):
return self
def __exit__(self, *_args):
return False
with pytest.raises(AppError) as exc_info:
with opener(object(), timeout=1, _transport=lambda *_a, **_k: _Redirected()):
pass
assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED"
def test_the_strict_policy_still_allows_the_response_it_asked_for(self) -> None:
from app.services.outbound_request_guard import guarded_opener
url = "https://geo.api.vlaanderen.be/GRB/wfs"
opener = guarded_opener(url, allow_redirect=False)
class _Direct:
def __init__(self) -> None:
self.url = url
def __enter__(self):
return self
def __exit__(self, *_args):
return False
with opener(object(), timeout=1, _transport=lambda *_a, **_k: _Direct()) as response:
assert response.url == url
def test_both_policies_refuse_a_private_destination(self) -> None:
from app.services.outbound_request_guard import guarded_opener
for allow_redirect in (True, False):
with pytest.raises(AppError) as exc_info:
guarded_opener("http://10.0.0.5/wfs", allow_redirect=allow_redirect)
assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
def test_the_strict_services_use_the_shared_guard(self) -> None:
"""Behavioural: their own fetch paths refuse a private endpoint, which
the hand-rolled opener never checked."""
from app.core.config import Settings
from app.services.grb_acquisition_service import GrbAcquisitionService
from app.services.official_vector_acquisition_service import OfficialVectorAcquisitionService
settings = Settings(_env_file=None)
for service in (GrbAcquisitionService, OfficialVectorAcquisitionService):
with pytest.raises(AppError) as exc_info:
service._read_page("http://127.0.0.1:9/wfs", settings, None)
assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
def test_a_refused_redirect_is_never_requested() -> None:
"""Rejecting after the fact still sends the request.
Checking ``response.url`` means urllib has already followed the chain: the
connection to the redirect target was opened and the response read. For a
destination like a metadata endpoint that is the whole attack. The strict
policy must refuse to follow, not refuse afterwards.
"""
from app.services.outbound_request_guard import no_redirect_opener
opener = no_redirect_opener()
handlers = [type(handler).__name__ for handler in opener.handlers]
assert "_RejectRedirects" in handlers
def test_the_rejecting_handler_returns_no_new_request() -> None:
from app.services.outbound_request_guard import _RejectRedirects
handler = _RejectRedirects()
assert handler.redirect_request(None, None, 302, "Found", {}, "http://169.254.169.254/") is None