Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion src/backend/tests/unit/components/git/test_gitextractor_ssrf.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""SSRF / RCE regression tests for the Git components' clone URL handling.
"""Security regression tests for the Git components.

A tenant-controlled repository URL handed to ``git clone`` enables RCE via the ``ext::``
remote helper, arbitrary local-file disclosure via ``file://`` / bare paths, and SSRF to
internal hosts. These tests confirm the dangerous URL never reaches ``git.Repo.clone_from``.
"""

from contextlib import asynccontextmanager
from unittest.mock import MagicMock, patch

import pytest

from tests.base import ComponentTestBaseWithoutClient


@pytest.fixture
def ssrf_on():
Expand Down Expand Up @@ -53,3 +56,62 @@ async def test_gitloader_blocks_dangerous_clone_url():
with pytest.raises((SSRFProtectionError, ValueError)):
await component.build_gitloader()
assert mock_loader.call_count == 0


class TestGitExtractorComponent(ComponentTestBaseWithoutClient):
@pytest.fixture
def component_class(self):
from lfx.components.git.gitextractor import GitExtractorComponent

return GitExtractorComponent

@pytest.fixture
def default_kwargs(self):
return {"repository_url": "https://example.com/repository.git"}

@pytest.fixture
def file_names_mapping(self):
return []

@pytest.fixture
def gitextractor_repo_with_symlink(self, component_class, default_kwargs, tmp_path, monkeypatch):
"""Provide a checked-out tree whose symlinks point outside the repository."""
repository = tmp_path / "repository"
repository.mkdir()
(repository / "README.md").write_bytes(b"repository content\n")

outside_file = tmp_path / "outside.txt"
outside_file.write_text("SAFE_CANARY\n" * 3, encoding="utf-8")
(repository / "linked.txt").symlink_to(outside_file)

outside_directory = tmp_path / "outside-directory"
outside_directory.mkdir()
(repository / "linked-directory").symlink_to(outside_directory, target_is_directory=True)

@asynccontextmanager
async def fake_temp_git_repo(_self):
yield str(repository)

monkeypatch.setattr(component_class, "temp_git_repo", fake_temp_git_repo)
return component_class(**default_kwargs)

async def test_files_content_skips_symlinks(self, gitextractor_repo_with_symlink):
result = await gitextractor_repo_with_symlink.get_files_content()

assert [item.data["path"] for item in result] == ["README.md"]
assert "SAFE_CANARY" not in result[0].data["content"]

async def test_text_content_skips_symlinks(self, gitextractor_repo_with_symlink):
result = await gitextractor_repo_with_symlink.get_text_based_file_contents()

assert "README.md" in result.text
assert "linked.txt" not in result.text
assert "SAFE_CANARY" not in result.text

async def test_statistics_skips_symlinks(self, gitextractor_repo_with_symlink):
result = await gitextractor_repo_with_symlink.get_statistics()

assert result[0].data["total_files"] == 1
assert result[0].data["total_lines"] == 1
assert result[0].data["total_size_bytes"] == len(b"repository content\n")
assert result[0].data["directories"] == 0
9 changes: 8 additions & 1 deletion src/bundles/lfx-bundles/src/lfx_bundles/git/gitextractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,13 @@ async def get_statistics(self) -> list[Data]:
directories = 0

for root, dirs, files in os.walk(temp_dir):
total_files += len(files)
dirs[:] = [directory for directory in dirs if not (Path(root) / directory).is_symlink()]
directories += len(dirs)
for file in files:
file_path = Path(root) / file
if file_path.is_symlink():
continue
total_files += 1
Comment thread
erichare marked this conversation as resolved.
total_size += file_path.stat().st_size
try:
async with aiofiles.open(file_path, encoding="utf-8") as f:
Expand Down Expand Up @@ -145,6 +148,8 @@ async def get_files_content(self) -> list[Data]:
for root, _, files in os.walk(temp_dir):
for file in files:
file_path = Path(root) / file
if file_path.is_symlink():
continue
relative_path = file_path.relative_to(temp_dir)
file_size = file_path.stat().st_size
try:
Expand Down Expand Up @@ -172,6 +177,8 @@ async def get_text_based_file_contents(self) -> Message:
for root, _, files in os.walk(temp_dir):
for file in files:
file_path = Path(root) / file
if file_path.is_symlink():
continue
relative_path = file_path.relative_to(temp_dir)
content_list.extend(["=" * 50, f"File: /{relative_path}", "=" * 50])

Expand Down
Loading