#!/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[A-Za-z0-9][A-Za-z0-9._-]{0,99})\.git/" r"(?Pinfo/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())