diff --git a/backend/app/main.py b/backend/app/main.py index d88dc1f..f262faa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import List +from urllib.parse import quote import aiosqlite import httpx @@ -67,6 +68,36 @@ from .scanners.semgrep import run_semgrep from .utils.fs import ensure_dir, safe_rmtree, unzip_to_dir +GIT_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") + + +def validate_git_ref(ref: str) -> str: + if not ref: + raise HTTPException(status_code=400, detail="Invalid Git reference") + + if len(ref) > 255: + raise HTTPException(status_code=400, detail="Invalid Git reference") + + if not GIT_REF_RE.fullmatch(ref): + raise HTTPException(status_code=400, detail="Invalid Git reference") + + forbidden = ( + "..", + "./", + "\\", + "//", + ) + + if ( + any(token in ref for token in forbidden) + or ref.startswith("/") + or ref.endswith("/") + ): + raise HTTPException(status_code=400, detail="Invalid Git reference") + + return ref + + _MAX_UPLOAD_MB_RAW = os.environ.get("MAX_UPLOAD_MB") RANKER = load_ranker() @@ -327,7 +358,8 @@ def github_zip_url(repo_url: str, ref: str = "main") -> str: status_code=400, detail="Only GitHub repo URLs are supported right now." ) owner, repo = m.group(1), m.group(2) - return f"https://github.com/{owner}/{repo}/archive/refs/heads/{ref}.zip" + encoded_ref = quote(ref, safe="/") + return f"https://github.com/{owner}/{repo}/archive/refs/heads/{encoded_ref}.zip" ALLOWED_REDIRECT_HOSTS = { @@ -660,7 +692,8 @@ async def scan_url( archive_path = job_dir / "repo.zip" repo_dir = job_dir / "repo" ensure_dir(repo_dir) - zip_url = github_zip_url(repo_url, ref=ref) + validated_ref = validate_git_ref(ref) + zip_url = github_zip_url(repo_url, ref=validated_ref) try: await download_to_path(zip_url, archive_path) diff --git a/backend/tests/test_scan_url.py b/backend/tests/test_scan_url.py index ff9c3c6..2a474b2 100644 --- a/backend/tests/test_scan_url.py +++ b/backend/tests/test_scan_url.py @@ -1,10 +1,57 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx +import pytest from fastapi.testclient import TestClient from app.main import app + +@pytest.mark.parametrize( + "ref", + [ + "main", + "feature/login", + "release-1.0", + ], +) +def test_scan_url_valid_refs(ref): + res = client.post( + "/scan-url", + data={ + "repo_url": "not-a-url", + "ref": ref, + "project_name": "test_project", + }, + ) + + # Validation should reach repo URL parsing, proving the ref was accepted. + assert res.status_code == 400 + assert "Only GitHub repo URLs are supported right now." in res.json()["detail"] + + +@pytest.mark.parametrize( + "ref", + [ + "../main", + "./main", + r"main\hack", + ], +) +def test_scan_url_invalid_refs(ref): + res = client.post( + "/scan-url", + data={ + "repo_url": "https://github.com/owner/repo", + "ref": ref, + "project_name": "test_project", + }, + ) + + assert res.status_code == 400 + assert res.json()["detail"] == "Invalid Git reference" + + client = TestClient(app)