From f0a033d7fdac098048d8da59a45e889cc92250b4 Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:42:51 +0530 Subject: [PATCH 1/3] fix(security): restrict repository clone transports --- backend/app/main.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index d0fd629..bccb466 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ipaddress import json import os import re @@ -8,6 +9,7 @@ import tempfile from pathlib import Path from typing import Any +from urllib.parse import urlsplit import httpx from fastapi import FastAPI, HTTPException @@ -78,12 +80,39 @@ def safe_repo_name(url: str) -> str: def safe_branch_name(name: str) -> str: - value = re.sub(r"[^a-zA-Z0-9._/-]+", "-", name.strip()).strip("/-." ) + value = re.sub(r"[^a-zA-Z0-9._/-]+", "-", name.strip()).strip("/-.") value = re.sub(r"/{2,}", "/", value) return value[:80] or "patchpilot/task" +def validate_repo_url(repo_url: str) -> str: + value = repo_url.strip() + parsed = urlsplit(value) + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise HTTPException(400, "repo_url must use HTTPS") + if parsed.username is not None or parsed.password is not None: + raise HTTPException(400, "repo_url must not contain embedded credentials") + if parsed.query or parsed.fragment: + raise HTTPException(400, "repo_url must not contain a query or fragment") + if parsed.path in {"", "/"}: + raise HTTPException(400, "repo_url must include a repository path") + + hostname = parsed.hostname.rstrip(".").lower() + if hostname == "localhost" or hostname.endswith(".localhost"): + raise HTTPException(400, "repo_url must not target localhost") + try: + address = ipaddress.ip_address(hostname) + except ValueError: + pass + else: + if not address.is_global: + raise HTTPException(400, "repo_url must not target a private or local IP address") + + return value + + def clone_repo(repo_url: str) -> tuple[str, Path]: + repo_url = validate_repo_url(repo_url) workspace_id = next(tempfile._get_candidate_names()) workdir = WORKSPACES / workspace_id workdir.mkdir(parents=True, exist_ok=False) From 0c4399c1bb170b19650a38cf5b8591f2500937bf Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:43:11 +0530 Subject: [PATCH 2/3] test(security): cover unsafe repository sources --- backend/tests/test_main.py | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 2ffeff7..594b40f 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -16,6 +16,7 @@ parse_json_object, safe_branch_name, safe_repo_name, + validate_repo_url, ) @@ -32,6 +33,52 @@ def test_safe_branch_name_normalizes_unsafe_input(self) -> None: "feat/unsafe-branch", ) + def test_validate_repo_url_accepts_public_https_repository(self) -> None: + self.assertEqual( + validate_repo_url(" https://github.com/example/project.git "), + "https://github.com/example/project.git", + ) + + def test_validate_repo_url_rejects_non_https_transports(self) -> None: + for repo_url in ( + "/tmp/local-repo", + "file:///tmp/local-repo", + "ssh://git@example.com/project.git", + "git@example.com:project.git", + "http://github.com/example/project.git", + ): + with self.subTest(repo_url=repo_url): + with self.assertRaises(HTTPException) as context: + validate_repo_url(repo_url) + self.assertEqual(context.exception.status_code, 400) + + def test_validate_repo_url_rejects_embedded_credentials(self) -> None: + with self.assertRaises(HTTPException) as context: + validate_repo_url("https://token@example.com/project.git") + self.assertEqual(context.exception.status_code, 400) + + def test_validate_repo_url_rejects_local_network_literals(self) -> None: + for repo_url in ( + "https://localhost/project.git", + "https://127.0.0.1/project.git", + "https://10.0.0.5/project.git", + "https://[::1]/project.git", + ): + with self.subTest(repo_url=repo_url): + with self.assertRaises(HTTPException) as context: + validate_repo_url(repo_url) + self.assertEqual(context.exception.status_code, 400) + + def test_validate_repo_url_rejects_query_and_fragment(self) -> None: + for repo_url in ( + "https://github.com/example/project.git?token=secret", + "https://github.com/example/project.git#branch", + ): + with self.subTest(repo_url=repo_url): + with self.assertRaises(HTTPException) as context: + validate_repo_url(repo_url) + self.assertEqual(context.exception.status_code, 400) + def test_parse_json_object_accepts_fenced_json(self) -> None: payload = parse_json_object('```json\n{"summary":"ok","plan":[]}\n```') self.assertEqual(payload, {"summary": "ok", "plan": []}) From 6d0a0025fc2f7d3746235811a4d2e1a1525e28e6 Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:17:52 +0530 Subject: [PATCH 3/3] chore(security): integrate workspace containment --- backend/tests/test_main.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 594b40f..55e1393 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -17,6 +17,7 @@ safe_branch_name, safe_repo_name, validate_repo_url, + workspace_repo, ) @@ -147,6 +148,28 @@ def test_apply_edits_is_atomic_when_later_edit_is_invalid(self) -> None: self.assertEqual(first.read_text(encoding="utf-8"), "alpha") self.assertEqual(second.read_text(encoding="utf-8"), "beta") + def test_workspace_repo_rejects_path_traversal(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) / "workspaces" + outside = Path(temp_dir) / "outside" / "repo" + root.mkdir() + outside.mkdir(parents=True) + + with patch("app.main.WORKSPACES", root): + with self.assertRaises(HTTPException) as context: + workspace_repo("../outside") + + self.assertEqual(context.exception.status_code, 400) + + def test_workspace_repo_returns_existing_repo_inside_root(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) / "workspaces" + repo = root / "safe-id" / "repo" + repo.mkdir(parents=True) + + with patch("app.main.WORKSPACES", root): + self.assertEqual(workspace_repo("safe-id"), repo.resolve()) + def test_github_headers_requires_token(self) -> None: with patch.dict(os.environ, {}, clear=True): with self.assertRaises(HTTPException) as context: