2026-08-16 23:12:08 -03:00

318 lines
12 KiB
Python

#!/usr/bin/env python3
"""Serve the credential-isolated Atlas API and Git smart-HTTP boundary."""
from __future__ import annotations
import argparse
import base64
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from gitea_api import (
CANONICAL_BASE_URL,
PolicyError,
create_draft,
read,
read_token,
)
from gitea_api_policy import _reject_forbidden, _validate_ref, _validate_repo
BROKER_PORT = 9081
GIT_USER = "hermes-automation"
MAX_CONTROL_BODY = 64 * 1024
MAX_GIT_REQUEST = 128 * 1024 * 1024
MAX_GIT_RESPONSE = 128 * 1024 * 1024
MAX_PUSH_COMMANDS = 16
ZERO_SHA = b"0" * 40
GIT_PATH_RE = re.compile(
r"/git/atlas/(?P<repo>[A-Za-z0-9][A-Za-z0-9._-]{0,99})\.git/"
r"(?P<operation>info/refs|git-upload-pack|git-receive-pack)\Z"
)
FEATURE_REF_RE = re.compile(
r"refs/heads/(?:(?:feature|fix|hermes|handoff)/[A-Za-z0-9][A-Za-z0-9._/-]{0,190})\Z"
)
class RejectRedirect(urllib.request.HTTPRedirectHandler):
"""Reject every upstream redirect before credentials can be forwarded."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise PolicyError("SCM upstream redirects are not allowed")
UPSTREAM_OPENER = urllib.request.build_opener(RejectRedirect())
def _status(response: object) -> int | None:
value = getattr(response, "status", None)
if value is None and hasattr(response, "getcode"):
value = response.getcode() # type: ignore[attr-defined]
return value
def _read_bounded(stream, maximum: int, length: int | None = None) -> bytes:
if length is not None and not 0 <= length <= maximum:
raise PolicyError("SCM request exceeds the safe size limit")
value = stream.read(maximum + 1 if length is None else length)
if len(value) > maximum or (length is not None and len(value) != length):
raise PolicyError("SCM request exceeds the safe size limit")
return value
def _load_json(handler: BaseHTTPRequestHandler) -> dict[str, object]:
if handler.headers.get("Transfer-Encoding"):
raise PolicyError("chunked broker control requests are not allowed")
if handler.headers.get_content_type() != "application/json":
raise PolicyError("broker control request must be JSON")
length = _content_length(handler.headers, MAX_CONTROL_BODY)
value = json.loads(_read_bounded(handler.rfile, MAX_CONTROL_BODY, length))
if not isinstance(value, dict):
raise PolicyError("broker control request must be an object")
return value
def _git_target(raw: str) -> tuple[str, str, str]:
if (
not raw.isascii()
or any(ord(character) < 32 or ord(character) == 127 for character in raw)
or "%" in raw
or "\\" in raw
or len(raw) > 512
):
raise PolicyError("Git target is not canonical ASCII")
target = urllib.parse.urlsplit(raw)
canonical = urllib.parse.urlunsplit(("", "", target.path, target.query, ""))
if canonical != raw:
raise PolicyError("Git target is not in exact canonical form")
if target.scheme or target.netloc or target.fragment:
raise PolicyError("Git target must be relative")
match = GIT_PATH_RE.fullmatch(target.path)
if not match:
raise PolicyError("Git target is outside the Atlas allowlist")
repo = _validate_repo(match.group("repo"))
operation = match.group("operation")
query = target.query
if operation == "info/refs":
if query not in {"service=git-upload-pack", "service=git-receive-pack"}:
raise PolicyError("Git discovery service is outside the allowlist")
service = query.removeprefix("service=")
elif query:
raise PolicyError("Git RPC query parameters are not allowed")
else:
service = operation
return repo, operation, service
def _content_length(headers: object, maximum: int) -> int:
"""Parse one short canonical bounded Content-Length header."""
raw = headers.get("Content-Length", "") # type: ignore[attr-defined]
if (
not isinstance(raw, str)
or not raw.isascii()
or len(raw) > 10
or not raw.isdigit()
):
raise PolicyError("SCM request length is invalid")
length = int(raw)
if raw != str(length) or not 0 <= length <= maximum:
raise PolicyError("SCM request length is invalid")
return length
def _credential_forms(token: str) -> tuple[bytes, ...]:
basic = base64.b64encode(f"{GIT_USER}:{token}".encode("utf-8"))
return token.encode("utf-8"), basic, b"Basic " + basic
def _reject_credential_bytes(value: bytes, token: str, context: str) -> None:
if any(form in value for form in _credential_forms(token)):
raise PolicyError(f"{context} contains runtime credential material")
def _validate_receive_pack(body: bytes, token: str) -> None:
"""Permit only creation of new, namespaced feature branches."""
_reject_credential_bytes(body, token, "Git request")
position = 0
commands = 0
while position + 4 <= len(body):
header = body[position : position + 4]
if not re.fullmatch(rb"[0-9a-f]{4}", header):
raise PolicyError("Git receive-pack command framing is invalid")
size = int(header, 16)
if size == 0:
if commands == 0:
raise PolicyError("Git receive-pack request has no ref command")
return
if size < 4 or position + size > len(body):
raise PolicyError("Git receive-pack command framing is invalid")
command = body[position + 4 : position + size].rstrip(b"\n")
command = command.split(b"\x00", 1)[0]
fields = command.split(b" ")
if len(fields) != 3 or not all(re.fullmatch(rb"[0-9a-f]{40}", item) for item in fields[:2]):
raise PolicyError("Git receive-pack ref command is invalid")
old_sha, new_sha, raw_ref = fields
if old_sha != ZERO_SHA or new_sha == ZERO_SHA:
raise PolicyError("Git broker permits only new feature-branch creation")
try:
ref = raw_ref.decode("ascii")
except UnicodeDecodeError as exc:
raise PolicyError("Git ref must be canonical ASCII") from exc
if not FEATURE_REF_RE.fullmatch(ref):
raise PolicyError("Git push is limited to namespaced feature branches")
_validate_ref(ref.removeprefix("refs/heads/"), "head", forbidden=(token,))
commands += 1
if commands > MAX_PUSH_COMMANDS:
raise PolicyError("Git push contains too many ref commands")
position += size
raise PolicyError("Git receive-pack request omitted its command terminator")
def _upstream_git_request(
target: str,
*,
method: str,
body: bytes | None,
content_type: str | None,
expected_type: str,
token: str,
opener=UPSTREAM_OPENER.open,
) -> bytes:
credentials = base64.b64encode(f"{GIT_USER}:{token}".encode()).decode("ascii")
headers = {"Accept": expected_type, "Authorization": f"Basic {credentials}"}
if content_type:
headers["Content-Type"] = content_type
request = urllib.request.Request(
CANONICAL_BASE_URL + target,
data=body,
method=method,
headers=headers,
)
with opener(request, timeout=120) as response:
if _status(response) != 200:
raise PolicyError("Git upstream returned an unexpected HTTP status")
if response.headers.get_content_type() != expected_type:
raise PolicyError("Git upstream returned an unexpected response type")
result = _read_bounded(response, MAX_GIT_RESPONSE)
_reject_credential_bytes(result, token, "Git upstream response")
return result
class BrokerHandler(BaseHTTPRequestHandler):
"""Expose only bounded metadata, draft creation, and smart-HTTP Git."""
server_version = "HermesSCMBroker/1"
sys_version = ""
def log_message(self, _format: str, *_args: object) -> None:
return
def _json(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _reject(self, status: int = 400) -> None:
self._json(status, b'{"error":"request rejected"}')
def do_GET(self) -> None:
try:
if self.path == "/healthz":
self._json(200, b'{"status":"ok"}')
return
repo, operation, service = _git_target(self.path)
if operation != "info/refs":
raise PolicyError("Git RPC requires POST")
token = read_token()
_reject_forbidden(repo, "repository", (token,))
expected = f"application/x-{service}-advertisement"
body = _upstream_git_request(
f"/atlas/{repo}.git/info/refs?service={service}",
method="GET",
body=None,
content_type=None,
expected_type=expected,
token=token,
)
self.send_response(200)
self.send_header("Content-Type", expected)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except (OSError, PolicyError, urllib.error.URLError, ValueError):
self._reject()
def do_POST(self) -> None:
try:
if self.path in {"/v1/metadata", "/v1/drafts"}:
self._control()
else:
self._git_rpc()
except (OSError, PolicyError, urllib.error.URLError, ValueError, json.JSONDecodeError):
self._reject()
def _control(self) -> None:
data = _load_json(self)
token = read_token()
if self.path == "/v1/metadata":
if set(data) != {"path"} or not isinstance(data["path"], str):
raise PolicyError("metadata request fields are invalid")
result = read(data["path"], token=token)
else:
expected = {"base", "body", "head", "head_sha", "repo", "title"}
if set(data) != expected or not all(isinstance(data[key], str) for key in expected):
raise PolicyError("draft request fields are invalid")
result = create_draft(token=token, **data) # type: ignore[arg-type]
if token.encode("utf-8") in result:
raise PolicyError("SCM upstream reflected credential material")
self._json(200, result)
def _git_rpc(self) -> None:
repo, operation, service = _git_target(self.path)
if operation not in {"git-upload-pack", "git-receive-pack"} or service != operation:
raise PolicyError("Git RPC operation is outside the allowlist")
expected_request = f"application/x-{service}-request"
if self.headers.get_content_type() != expected_request or self.headers.get("Transfer-Encoding"):
raise PolicyError("Git RPC request type is invalid")
length = _content_length(self.headers, MAX_GIT_REQUEST)
body = _read_bounded(self.rfile, MAX_GIT_REQUEST, length)
token = read_token()
_reject_forbidden(repo, "repository", (token,))
if service == "git-receive-pack":
_validate_receive_pack(body, token)
else:
_reject_credential_bytes(body, token, "Git request")
expected = f"application/x-{service}-result"
result = _upstream_git_request(
f"/atlas/{repo}.git/{service}",
method="POST",
body=body,
content_type=expected_request,
expected_type=expected,
token=token,
)
self.send_response(200)
self.send_header("Content-Type", expected)
self.send_header("Content-Length", str(len(result)))
self.end_headers()
self.wfile.write(result)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--listen", default="0.0.0.0")
parser.add_argument("--port", type=int, default=BROKER_PORT)
args = parser.parse_args()
ThreadingHTTPServer((args.listen, args.port), BrokerHandler).serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())