jenkins 578239a496 hermes: bound SCM control calls with hard deadlines
Run every Gitea API and broker control exchange inside a killable helper
process whose connect, send, and read share one absolute wall-clock
deadline, and add a watchdog that force-closes streaming connections at
expiry. Redirects are rejected before authentication headers can move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:15:35 -03:00

303 lines
10 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)
)
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 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._track(
http.client.HTTPConnection(host, **kwargs)
),
req,
)
class GuardedHTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req):
return self.do_open(
lambda host, **kwargs: deadline._track(
http.client.HTTPSConnection(host, **kwargs)
),
req,
)
return (GuardedHTTPHandler(), GuardedHTTPSHandler())
if __name__ == "__main__":
raise SystemExit(_child_main() if sys.argv[1:] == ["--child"] else 2)