diff --git a/api/config.py b/api/config.py index ab9877271..f136063fd 100644 --- a/api/config.py +++ b/api/config.py @@ -505,6 +505,11 @@ def iterate_files( results: list[str] = [] for p in Path(root_dir).rglob("*"): + if p.is_symlink(): + # A repository-controlled symlink can point outside the clone; + # following it would read/embed a host file the repository + # author does not own. Skip it rather than resolve+open it. + continue if not p.is_file(): continue if extensions and p.suffix.lower() not in extensions: diff --git a/tests/test_config.py b/tests/test_config.py index a4987bcd3..830977fe7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,5 @@ +import os + import pytest from api.config import iterate_files @@ -60,3 +62,26 @@ def test_iterate_files_excluded_dirs(exclude_test_config, tmp_path, excluded_dir make_repo(tmp_path) files = set(iterate_files(root_dir=str(tmp_path), excluded_dirs=excluded_dirs)) assert files == {"README.md", "CHANGELOG.md"} + + +def test_iterate_files_does_not_follow_symlink_out_of_repository( + exclude_test_config, tmp_path +): + """A repository-controlled symlink pointing outside the clone must not + be listed for indexing. Regression for the symlink-escape bug: the + walker used to call is_file() (which follows the link) with no + is_symlink() guard, so the indexer would open and embed the external + target's content.""" + make_repo(tmp_path) + + outside_dir = tmp_path.parent / "outside_the_clone" + outside_dir.mkdir(exist_ok=True) + outside_secret = outside_dir / "secret.py" + outside_secret.write_text("DB_PASSWORD = 'outside-secret'") + + alias = tmp_path / "innocuous_link.py" + os.symlink(outside_secret, alias) + + files = set(iterate_files(root_dir=str(tmp_path))) + assert "innocuous_link.py" not in files + assert files == {"README.md", "CHANGELOG.md", "folder/code.py"}