diff --git a/opax-mcp/gitea_handler.py b/opax-mcp/gitea_handler.py index db7060f..b13f646 100644 --- a/opax-mcp/gitea_handler.py +++ b/opax-mcp/gitea_handler.py @@ -2,13 +2,149 @@ import os import httpx import logging from urllib.parse import quote +import re logger = logging.getLogger(__name__) +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_REPO_ID_RE = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/" + r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}$" +) +_BRANCH_RE = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$" +) + +MAX_SOURCE_ARCHIVE_BYTES = int( + os.environ.get("OPAX_MAX_SOURCE_ARCHIVE_BYTES", "104857600") +) + def _gitea_headers() -> dict: """Constructs headers for Gitea API requests.""" return {"Authorization": f"token {os.environ.get('GITEA_TOKEN')}", "Accept": "application/json"} +def _validate_repo_id(repo_id: str) -> str: + if not isinstance(repo_id, str) or not _REPO_ID_RE.fullmatch(repo_id): + raise ValueError("Invalid configured Gitea repository ID") + return repo_id + + +def _validate_gitea_url(gitea_url: str) -> str: + if not isinstance(gitea_url, str): + raise ValueError("GITEA_URL environment variable is not set") + + normalized = gitea_url.rstrip("/") + + if not normalized.startswith("https://"): + raise ValueError("GITEA_URL must use HTTPS") + + return normalized + + +def _validate_branch_name(branch_name: str) -> str: + if not isinstance(branch_name, str): + raise ValueError("Branch name must be a string") + + if not _BRANCH_RE.fullmatch(branch_name): + raise ValueError("Invalid branch name") + + if ( + branch_name.startswith((".", "/")) + or branch_name.endswith((".", "/")) + or ".." in branch_name + or "//" in branch_name + or "@{" in branch_name + or branch_name.endswith(".lock") + ): + raise ValueError("Invalid branch name") + + return branch_name + + +def _validate_commit_sha(commit_sha: str) -> str: + if not isinstance(commit_sha, str) or not _SHA_RE.fullmatch(commit_sha): + raise ValueError("Invalid commit SHA") + + return commit_sha + + +async def resolve_branch_to_commit_sha( + branch_name: str, + repo_id: str, + gitea_url: str, +) -> str: + """Resolve an allowed branch name to one immutable 40-character SHA.""" + validated_branch = _validate_branch_name(branch_name) + validated_repo = _validate_repo_id(repo_id) + validated_url = _validate_gitea_url(gitea_url) + + encoded_branch = quote(validated_branch, safe="") + url = ( + f"{validated_url}/api/v1/repos/" + f"{validated_repo}/branches/{encoded_branch}" + ) + + async with httpx.AsyncClient( + timeout=httpx.Timeout(15.0), + follow_redirects=False, + ) as client: + response = await client.get( + url, + headers=_gitea_headers(), + ) + + response.raise_for_status() + + commit_sha = response.json().get("commit", {}).get("id") + return _validate_commit_sha(commit_sha) + + +async def download_repo_archive( + commit_sha: str, + repo_id: str, + gitea_url: str, +) -> bytes: + """Download a bounded source archive for an already resolved SHA.""" + validated_sha = _validate_commit_sha(commit_sha) + validated_repo = _validate_repo_id(repo_id) + validated_url = _validate_gitea_url(gitea_url) + + url = ( + f"{validated_url}/api/v1/repos/" + f"{validated_repo}/archive/{validated_sha}.tar.gz" + ) + + async with httpx.AsyncClient( + timeout=httpx.Timeout(connect=15.0, read=60.0, write=15.0, pool=15.0), + follow_redirects=False, + ) as client: + async with client.stream( + "GET", + url, + headers=_gitea_headers(), + ) as response: + response.raise_for_status() + + content_length = response.headers.get("content-length") + if ( + content_length is not None + and int(content_length) > MAX_SOURCE_ARCHIVE_BYTES + ): + raise ValueError("Source archive exceeds allowed size") + + chunks = [] + total_bytes = 0 + + async for chunk in response.aiter_bytes(): + total_bytes += len(chunk) + + if total_bytes > MAX_SOURCE_ARCHIVE_BYTES: + raise ValueError("Source archive exceeds allowed size") + + chunks.append(chunk) + + return b"".join(chunks) + async def handle_get_file_content(p: dict, default_repo: str) -> dict: """Gets the raw content of a file from the Gitea repository.""" gitea_url = os.environ.get("GITEA_URL") diff --git a/opax-mcp/test_gitea_helpers.py b/opax-mcp/test_gitea_helpers.py new file mode 100644 index 0000000..890e2e7 --- /dev/null +++ b/opax-mcp/test_gitea_helpers.py @@ -0,0 +1,137 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "opax-mcp")) + +import gitea_handler + +def make_stream_context(response): + context = MagicMock() + context.__aenter__ = AsyncMock(return_value=response) + context.__aexit__ = AsyncMock(return_value=False) + return context + +class TestGiteaHelpers(unittest.IsolatedAsyncioTestCase): + def test_validate_branch_name_valid(self): + self.assertEqual(gitea_handler._validate_branch_name("main"), "main") + self.assertEqual(gitea_handler._validate_branch_name("feat/opax-domain-decouple"), "feat/opax-domain-decouple") + + def test_validate_branch_name_invalid(self): + with self.assertRaises(ValueError): + gitea_handler._validate_branch_name("../../etc/passwd") + with self.assertRaises(ValueError): + gitea_handler._validate_branch_name("main@{bad}") + with self.assertRaises(ValueError): + gitea_handler._validate_branch_name("feat//bad") + + def test_validate_commit_sha_valid(self): + self.assertEqual(gitea_handler._validate_commit_sha("395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9"), "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9") + + def test_validate_commit_sha_invalid(self): + with self.assertRaises(ValueError): + gitea_handler._validate_commit_sha("main") + with self.assertRaises(ValueError): + gitea_handler._validate_commit_sha("395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c") # 39 chars + + def test_validate_gitea_url_https_required(self): + with self.assertRaises(ValueError): + gitea_handler._validate_gitea_url("http://example.com") + + @patch("gitea_handler.httpx.AsyncClient") + async def test_resolve_branch_to_commit_sha(self, mock_client): + mock_response = unittest.mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"commit": {"id": "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9"}} + mock_client.return_value.__aenter__.return_value.get.return_value = mock_response + + sha = await gitea_handler.resolve_branch_to_commit_sha("main", "chris/OSVauco", "https://gitea.example.com") + self.assertEqual(sha, "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9") + + @patch("gitea_handler.httpx.AsyncClient") + async def test_download_repo_archive_uses_sha(self, mock_client): + response = MagicMock() + response.headers = {} + response.raise_for_status = MagicMock() + + async def aiter_bytes(): + yield b"archive-data" + + response.aiter_bytes = aiter_bytes + + stream_context = make_stream_context(response) + client = mock_client.return_value.__aenter__.return_value + client.stream = MagicMock(return_value=stream_context) + + archive = await gitea_handler.download_repo_archive( + "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9", + "chris/OSVauco", + "https://gitea.example.com", + ) + + self.assertEqual(archive, b"archive-data") + + client.stream.assert_called_once_with( + "GET", + ( + "https://gitea.example.com/api/v1/repos/" + "chris/OSVauco/archive/" + "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9.tar.gz" + ), + headers=unittest.mock.ANY, + ) + + @patch("gitea_handler.httpx.AsyncClient") + async def test_download_repo_archive_size_limit(self, mock_client): + response = MagicMock() + response.headers = { + "content-length": str( + gitea_handler.MAX_SOURCE_ARCHIVE_BYTES + 1 + ) + } + response.raise_for_status = MagicMock() + + stream_context = make_stream_context(response) + client = mock_client.return_value.__aenter__.return_value + client.stream = MagicMock(return_value=stream_context) + + with self.assertRaisesRegex( + ValueError, + "Source archive exceeds allowed size", + ): + await gitea_handler.download_repo_archive( + "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9", + "chris/OSVauco", + "https://gitea.example.com", + ) + + @patch("gitea_handler.httpx.AsyncClient") + async def test_download_repo_archive_no_redirects(self, mock_client): + response = MagicMock() + response.headers = {} + response.raise_for_status = MagicMock() + + async def aiter_bytes(): + yield b"" + + response.aiter_bytes = aiter_bytes + + stream_context = make_stream_context(response) + client = mock_client.return_value.__aenter__.return_value + client.stream = MagicMock(return_value=stream_context) + + await gitea_handler.download_repo_archive( + "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9", + "chris/OSVauco", + "https://gitea.example.com", + ) + + self.assertFalse( + mock_client.call_args.kwargs["follow_redirects"] + ) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file