318 lines
12 KiB
Python
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())
|