The watchdog can only close a live socket, but getaddrinfo runs before any socket exists and ignores socket timeouts, so a slow resolver outlived the stream deadline. Resolve under the same absolute deadline in a joinable worker before the real connect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
345 lines
12 KiB
Python
345 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Absolute wall-clock deadlines for every outbound Hermes HTTP exchange.
|
|
|
|
Small control exchanges run inside a killable helper process, so connect,
|
|
send, read, and DNS all share one hard bound that no blocking socket state
|
|
can outlive. Large streaming Git exchanges cannot cross a process boundary;
|
|
they instead register their connection with a watchdog that force-closes the
|
|
socket when the same kind of absolute deadline expires.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import contextlib
|
|
import email.message
|
|
import http.client
|
|
import io
|
|
import json
|
|
import os
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
|
|
from gitea_api_policy import PolicyError
|
|
|
|
MAX_CONTROL_BYTES = 128 * 1024
|
|
MAX_PROTOCOL_BYTES = 4 * 1024 * 1024
|
|
MAX_STREAM_SECONDS = 900.0
|
|
READ_CHUNK = 64 * 1024
|
|
|
|
|
|
class _RejectRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
raise urllib.error.URLError("redirect rejected")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Result:
|
|
"""Minimal HTTP evidence returned across the process boundary."""
|
|
|
|
status: int
|
|
content_type: str
|
|
body: bytes
|
|
|
|
|
|
def _read_response(response, maximum: int, deadline: float) -> bytes:
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError
|
|
raw = getattr(getattr(response, "fp", None), "raw", None)
|
|
sock = getattr(raw, "_sock", raw)
|
|
if hasattr(sock, "settimeout"):
|
|
sock.settimeout(remaining)
|
|
chunk = response.read(min(READ_CHUNK, maximum + 1 - total))
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
total += len(chunk)
|
|
if total > maximum:
|
|
raise ValueError("response too large")
|
|
return b"".join(chunks)
|
|
|
|
|
|
def _child_exchange(control: dict[str, object]) -> Result:
|
|
url = control.get("url")
|
|
method = control.get("method")
|
|
headers = control.get("headers")
|
|
encoded = control.get("body")
|
|
maximum = control.get("maximum")
|
|
timeout = control.get("timeout")
|
|
if (
|
|
not isinstance(url, str)
|
|
or not isinstance(method, str)
|
|
or not isinstance(headers, dict)
|
|
or not all(isinstance(key, str) and isinstance(value, str) for key, value in headers.items())
|
|
or not isinstance(encoded, str)
|
|
or not isinstance(maximum, int)
|
|
or not 0 <= maximum <= 2 * 1024 * 1024
|
|
or not isinstance(timeout, (int, float))
|
|
or not 0 < timeout <= 120
|
|
):
|
|
raise ValueError("invalid control")
|
|
body = base64.b64decode(encoded, validate=True) if encoded else None
|
|
if body is not None and len(body) > MAX_CONTROL_BYTES:
|
|
raise ValueError("request too large")
|
|
deadline = time.monotonic() + float(timeout)
|
|
request = urllib.request.Request(
|
|
url, data=body, method=method, headers=headers # type: ignore[arg-type]
|
|
)
|
|
opener = urllib.request.build_opener(_RejectRedirect())
|
|
try:
|
|
response = opener.open(request, timeout=max(deadline - time.monotonic(), 0.001))
|
|
except urllib.error.HTTPError as error:
|
|
response = error
|
|
with response:
|
|
status = getattr(response, "status", response.getcode())
|
|
content_type = response.headers.get_content_type()
|
|
value = _read_response(response, maximum, deadline)
|
|
return Result(int(status), str(content_type), value)
|
|
|
|
|
|
def _child_main() -> int:
|
|
try:
|
|
raw = sys.stdin.buffer.read(MAX_CONTROL_BYTES + 1)
|
|
if len(raw) > MAX_CONTROL_BYTES:
|
|
raise ValueError("control too large")
|
|
control = json.loads(raw)
|
|
if not isinstance(control, dict):
|
|
raise ValueError("invalid control")
|
|
result = _child_exchange(control)
|
|
output = {
|
|
"ok": True,
|
|
"status": result.status,
|
|
"content_type": result.content_type,
|
|
"body": base64.b64encode(result.body).decode("ascii"),
|
|
}
|
|
except Exception:
|
|
output = {"ok": False}
|
|
sys.stdout.write(json.dumps(output, separators=(",", ":")))
|
|
return 0 if output["ok"] else 1
|
|
|
|
|
|
def _terminate(process: subprocess.Popen[bytes]) -> None:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
process.communicate()
|
|
|
|
|
|
def exchange(
|
|
request: urllib.request.Request,
|
|
*,
|
|
maximum: int,
|
|
timeout: float,
|
|
popen=subprocess.Popen,
|
|
) -> Result:
|
|
"""Execute a whole request under one monotonic deadline and size limit."""
|
|
if not 0 < timeout <= 120 or not 0 <= maximum <= 2 * 1024 * 1024:
|
|
raise PolicyError("HTTP control request bounds are invalid")
|
|
body = request.data or b""
|
|
if not isinstance(body, bytes) or len(body) > MAX_CONTROL_BYTES:
|
|
raise PolicyError("HTTP control request exceeds the safe size limit")
|
|
control = json.dumps(
|
|
{
|
|
"url": request.full_url,
|
|
"method": request.get_method(),
|
|
"headers": dict(request.header_items()),
|
|
"body": base64.b64encode(body).decode("ascii"),
|
|
"maximum": maximum,
|
|
"timeout": timeout,
|
|
},
|
|
separators=(",", ":"),
|
|
).encode()
|
|
deadline = time.monotonic() + timeout
|
|
process = popen(
|
|
[sys.executable, os.path.realpath(__file__), "--child"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
env={"LANG": "C.UTF-8", "PATH": "/usr/bin:/bin"},
|
|
)
|
|
try:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise subprocess.TimeoutExpired(process.args, timeout)
|
|
output, _error = process.communicate(control, timeout=remaining)
|
|
except subprocess.TimeoutExpired as exc:
|
|
_terminate(process)
|
|
raise PolicyError("HTTP control request deadline exceeded") from exc
|
|
if process.returncode != 0 or len(output) > MAX_PROTOCOL_BYTES:
|
|
raise PolicyError("HTTP control request failed")
|
|
try:
|
|
result = json.loads(output)
|
|
if not isinstance(result, dict) or result.get("ok") is not True:
|
|
raise ValueError
|
|
status = result["status"]
|
|
content_type = result["content_type"]
|
|
encoded = result["body"]
|
|
if not isinstance(status, int) or not isinstance(content_type, str) or not isinstance(encoded, str):
|
|
raise ValueError
|
|
value = base64.b64decode(encoded, validate=True)
|
|
except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
raise PolicyError("HTTP control helper returned invalid evidence") from exc
|
|
if len(value) > maximum:
|
|
raise PolicyError("HTTP control response exceeds the safe size limit")
|
|
return Result(status, content_type, value)
|
|
|
|
|
|
class _AdaptedResponse:
|
|
"""Present one finished exchange through the urlopen reader interface."""
|
|
|
|
def __init__(self, result: Result):
|
|
self.status = result.status
|
|
self.headers = email.message.Message()
|
|
self.headers["Content-Type"] = result.content_type
|
|
self._stream = io.BytesIO(result.body)
|
|
|
|
def read(self, limit: int = -1) -> bytes:
|
|
return self._stream.read(limit)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_excinfo: object) -> bool:
|
|
return False
|
|
|
|
|
|
def open_bounded(
|
|
request: urllib.request.Request,
|
|
*,
|
|
maximum: int,
|
|
timeout: float,
|
|
popen=subprocess.Popen,
|
|
) -> _AdaptedResponse:
|
|
"""Open one control exchange whose whole lifetime shares one deadline."""
|
|
return _AdaptedResponse(
|
|
exchange(request, maximum=maximum, timeout=timeout, popen=popen)
|
|
)
|
|
|
|
|
|
def _resolve_within(host: str, port: int, timeout: float) -> None:
|
|
"""Resolve one host under a hard timeout the socket layer never covers.
|
|
|
|
``getaddrinfo`` ignores socket timeouts and runs before any socket
|
|
exists, so a slow or hostile resolver would otherwise outlive the whole
|
|
stream deadline. Running it in a joinable worker bounds resolution by the
|
|
same wall clock; a stuck lookup leaks only one daemon thread.
|
|
"""
|
|
outcome: dict[str, object] = {}
|
|
|
|
def run() -> None:
|
|
try:
|
|
outcome["value"] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
except OSError as exc:
|
|
outcome["error"] = exc
|
|
|
|
worker = threading.Thread(target=run, daemon=True)
|
|
worker.start()
|
|
worker.join(timeout)
|
|
if worker.is_alive():
|
|
raise PolicyError("HTTP stream DNS resolution deadline exceeded")
|
|
if "error" in outcome:
|
|
raise outcome["error"] # type: ignore[misc]
|
|
|
|
|
|
class StreamDeadline:
|
|
"""Force-close tracked connections once an absolute deadline passes."""
|
|
|
|
def __init__(self, timeout: float, *, timer=threading.Timer):
|
|
if not 0 < timeout <= MAX_STREAM_SECONDS:
|
|
raise PolicyError("HTTP stream deadline bounds are invalid")
|
|
self._deadline = time.monotonic() + timeout
|
|
self._lock = threading.Lock()
|
|
self._connections: list[http.client.HTTPConnection] = []
|
|
self.expired = False
|
|
self._timer = timer(timeout, self._expire)
|
|
self._timer.daemon = True
|
|
self._timer.start()
|
|
|
|
def remaining(self) -> float:
|
|
value = self._deadline - time.monotonic()
|
|
if value <= 0 or self.expired:
|
|
raise PolicyError("HTTP stream deadline exceeded")
|
|
return value
|
|
|
|
def _expire(self) -> None:
|
|
with self._lock:
|
|
self.expired = True
|
|
connections = list(self._connections)
|
|
for connection in connections:
|
|
# Shut the raw socket down first so a blocked send or recv wakes
|
|
# immediately; close alone leaves peers waiting on the old fd.
|
|
sock = getattr(connection, "sock", None)
|
|
if sock is not None:
|
|
with contextlib.suppress(OSError):
|
|
sock.shutdown(socket.SHUT_RDWR)
|
|
connection.close()
|
|
|
|
def _track(self, connection: http.client.HTTPConnection):
|
|
with self._lock:
|
|
expired = self.expired
|
|
if not expired:
|
|
self._connections.append(connection)
|
|
if expired:
|
|
connection.close()
|
|
raise PolicyError("HTTP stream deadline exceeded")
|
|
return connection
|
|
|
|
def _bounded_connection(self, connection: http.client.HTTPConnection):
|
|
"""Register one connection and bound its DNS phase by the deadline.
|
|
|
|
The watchdog can only close a live socket, but ``connect`` resolves
|
|
the host before any socket exists. Wrapping ``connect`` runs that
|
|
resolution under the same absolute deadline first, then hands off to
|
|
the real connect for the socket phases the watchdog already covers.
|
|
"""
|
|
original_connect = connection.connect
|
|
|
|
def connect() -> None:
|
|
_resolve_within(connection.host, connection.port, self.remaining())
|
|
original_connect()
|
|
|
|
connection.connect = connect # type: ignore[method-assign]
|
|
return self._track(connection)
|
|
|
|
def cancel(self) -> None:
|
|
self._timer.cancel()
|
|
|
|
def handlers(self) -> tuple[urllib.request.BaseHandler, ...]:
|
|
"""Build urllib handlers whose connections obey this deadline."""
|
|
deadline = self
|
|
|
|
class GuardedHTTPHandler(urllib.request.HTTPHandler):
|
|
def http_open(self, req):
|
|
return self.do_open(
|
|
lambda host, **kwargs: deadline._bounded_connection(
|
|
http.client.HTTPConnection(host, **kwargs)
|
|
),
|
|
req,
|
|
)
|
|
|
|
class GuardedHTTPSHandler(urllib.request.HTTPSHandler):
|
|
def https_open(self, req):
|
|
return self.do_open(
|
|
lambda host, **kwargs: deadline._bounded_connection(
|
|
http.client.HTTPSConnection(host, **kwargs)
|
|
),
|
|
req,
|
|
)
|
|
|
|
return (GuardedHTTPHandler(), GuardedHTTPSHandler())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_child_main() if sys.argv[1:] == ["--child"] else 2)
|