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:
@@ -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
|
||||
Reference in New Issue
Block a user