claim analysis jobs atomically and keep acquisition on its official host

Two defects of the same kind: work that is supposed to be bounded is not.

The analysis worker selected queued jobs and then set them to running in a
second statement. A restarted process overlapping the previous one, or a second
replica, could both select the same row and both start tiled GPU inference on
it — duplicate analysis runs and double the GPU load. The AOI worker beside it
already claims with FOR UPDATE SKIP LOCKED; this uses a conditional update,
which is the same guarantee in one statement. run_once now reports jobs it
actually claimed rather than jobs it looked at.

urlopen follows redirects, so although every acquisition URL is built from
settings and cannot be steered by a request payload, a misconfigured or
compromised upstream could send the runtime to the loopback interface, to
another container on the compose network, or to a cloud metadata endpoint — and
the bytes would then be persisted under an official provenance. That is exactly
the substitution the product forbids. All eight fetch sites now open through a
guard that refuses private, loopback and link-local destinations (resolving the
host first, so a DNS name cannot hide one) and refuses a redirect that leaves
the configured origin or downgrades from HTTPS.

The guard is proven by calling the services' own fetch paths, not by grepping
for the call: every existing acquisition test injects an opener, which bypasses
it by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 15:59:01 +02:00
co-authored by Claude Opus 5
parent 5b3839dc89
commit 16dedeb670
14 changed files with 493 additions and 16 deletions
+22 -8
View File
@@ -65,16 +65,26 @@ class AnalysisJobWorker:
return SegmentationService.run_segmentation(**common)
@staticmethod
def _claim(db, job: Job) -> None:
"""Take the job out of the queue before doing any work on it.
def claim(db, job: Job) -> bool:
"""Take the job out of the queue, atomically. Returns whether we won.
Without this the next poll would pick the same row up again while the
first execution is still running on the GPU.
Selecting and then updating in a second statement lets two workers —
a restarted process overlapping the previous one, or a second replica —
both start tiled GPU inference on the same row. The conditional update
makes exactly one caller see a row count of 1; the AOI worker beside
this one already claims with FOR UPDATE SKIP LOCKED for the same reason.
"""
job.status = "running"
db.add(job)
claimed = (
db.query(Job)
.filter(Job.id == job.id, Job.status == "queued")
.update({Job.status: "running"}, synchronize_session=False)
)
db.commit()
if not claimed:
return False
job.status = "running"
return True
@staticmethod
def _finalize(db, job: Job, result: Any) -> None:
@@ -132,9 +142,13 @@ class AnalysisJobWorker:
)
if job.job_type in AnalysisJobWorker.HANDLED_JOB_TYPES and job.status == "queued"
]
claimed_count = 0
for job in rows:
if not AnalysisJobWorker.claim(session, job):
# Another worker took it between the select and the claim.
continue
claimed_count += 1
try:
AnalysisJobWorker._claim(session, job)
result = AnalysisJobWorker._dispatch(session, job)
AnalysisJobWorker._finalize(session, job, result)
except Exception as exc:
@@ -142,7 +156,7 @@ class AnalysisJobWorker:
message = getattr(exc, "message", None) or str(exc) or "Unexpected analysis job failure"
AnalysisJobWorker._mark_failed(session, job, code=str(code), message=str(message))
logger.exception("Analysis job failed job_id=%s job_type=%s", job.id, job.job_type)
return len(rows)
return claimed_count
finally:
if owns_session:
session.close()
@@ -15,6 +15,7 @@ from shapely.geometry import Point, box, mapping
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, DatasetVersion, Project
from app.schemas.bathymetry import (
BathymetryPartitionFinalizeRequest,
@@ -224,7 +225,7 @@ class BathymetryProfileAcquisitionService:
},
)
try:
with (opener or urlopen)(request, timeout=settings.bathymetry_profiles_timeout_seconds) as response:
with (opener or guarded_opener(url))(request, timeout=settings.bathymetry_profiles_timeout_seconds) as response:
limit = settings.bathymetry_profiles_max_response_mb * 1024 * 1024
content = response.read(limit + 1)
except HTTPError as exc:
@@ -22,6 +22,7 @@ from shapely.ops import transform as shapely_transform
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.dhmv import DhmvAcquireRequest, DhmvAcquisitionResult, DhmvProductRead
from app.services.dataset_service import DatasetService
@@ -294,7 +295,7 @@ class DhmvAcquisitionService:
)
max_bytes = settings.dhmv_max_response_mb * 1024 * 1024
try:
with (opener or urlopen)(request, timeout=settings.dhmv_timeout_seconds) as response:
with (opener or guarded_opener(request_url))(request, timeout=settings.dhmv_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", ""))
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > max_bytes:
@@ -23,6 +23,7 @@ from shapely.ops import transform as shapely_transform
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.flood_hazard import FloodHazardAcquireRequest, FloodHazardAcquisitionResult, FloodHazardProductRead
from app.services.dataset_service import DatasetService
@@ -256,7 +257,7 @@ class FloodHazardAcquisitionService:
request = Request(request_url, headers={"Accept": "*/*", "User-Agent": "GeoIntel/0.1 bounded-vmm-flood-hazard-acquisition"})
max_bytes = settings.flood_hazard_max_response_mb * 1024 * 1024
try:
with (opener or urlopen)(request, timeout=settings.flood_hazard_timeout_seconds) as response:
with (opener or guarded_opener(request_url))(request, timeout=settings.flood_hazard_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", ""))
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > max_bytes:
@@ -10,6 +10,7 @@ from uuid import UUID
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 Dataset
from app.schemas.bathymetry import MdkBathymetryAcquireRequest, MdkBathymetryAcquisitionResult
from app.services.dataset_service import DatasetService
@@ -236,7 +237,7 @@ class MdkBathymetryAcquisitionService:
)
max_bytes = settings.mdk_bathymetry_acquisition_max_response_mb * 1024 * 1024
try:
with (opener or urlopen)(request, timeout=settings.mdk_bathymetry_acquisition_timeout_seconds) as response:
with (opener or guarded_opener(request_url))(request, timeout=settings.mdk_bathymetry_acquisition_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", "")) if hasattr(response, "headers") else ""
content = response.read(max_bytes + 1)
except HTTPError as exc:
@@ -43,7 +43,7 @@ class MdkBathymetryProbeService:
"User-Agent": "GeoIntel/1.0 MDK-bathymetry-readiness-probe",
},
)
with (opener or urlopen)(request, timeout=settings.mdk_bathymetry_probe_timeout_seconds) as response:
with (opener or guarded_opener(capabilities_url))(request, timeout=settings.mdk_bathymetry_probe_timeout_seconds) as response:
limit = settings.mdk_bathymetry_probe_max_response_mb * 1024 * 1024
content = response.read(limit + 1)
if len(content) > limit:
@@ -21,6 +21,7 @@ from shapely.ops import transform as shapely_transform
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.orthophoto import OrthophotoAcquireRequest, OrthophotoAcquisitionResult, OrthophotoProductRead
from app.services.dataset_service import DatasetService
@@ -469,7 +470,7 @@ class OrthophotoAcquisitionService:
@staticmethod
def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]:
request = Request(request_url, headers={"User-Agent": "GeoIntel/0.1 bounded-orthophoto-acquisition"})
open_request = opener or urlopen
open_request = opener or guarded_opener(request_url)
try:
with open_request(request, timeout=settings.orthophoto_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", ""))
@@ -0,0 +1,142 @@
"""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 urlopen
from app.core.errors import AppError
ALLOWED_SCHEMES = {"http", "https"}
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) -> 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.
"""
assert_public_http_url(expected_url)
def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
response = (_transport or urlopen)(request, *args, **kwargs)
final_url = str(getattr(response, "url", "") or "")
try:
assert_same_origin_redirect(expected_url, final_url)
except AppError:
close = getattr(response, "close", None)
if callable(close):
close()
raise
return response
return _open
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
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 Dataset, Project
from app.schemas.source_catalog import (
SourceCatalogProbeItem,
@@ -179,7 +180,7 @@ def _bounded_fetch(
)
max_bytes = (max_response_mb or settings.source_catalog_probe_max_response_mb) * 1024 * 1024
try:
with (opener or urlopen)(request, timeout=settings.source_catalog_probe_timeout_seconds) as response:
with (opener or guarded_opener(url))(request, timeout=settings.source_catalog_probe_timeout_seconds) as response:
content_length = _header(response.headers, "Content-Length")
if content_length:
try:
@@ -21,6 +21,7 @@ from shapely.ops import transform as shapely_transform
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.thematic_raster import (
ThematicRasterAcquireRequest,
@@ -359,7 +360,7 @@ class ThematicRasterAcquisitionService:
max_bytes = settings.thematic_raster_max_response_mb * 1024 * 1024
for attempt in range(1, ThematicRasterAcquisitionService.WCS_FETCH_ATTEMPTS + 1):
try:
with (opener or urlopen)(request, timeout=settings.thematic_raster_timeout_seconds) as response:
with (opener or guarded_opener(request_url))(request, timeout=settings.thematic_raster_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", ""))
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > max_bytes: