diff --git a/src/extension_shield/api/main.py b/src/extension_shield/api/main.py index 3ebbc4c..fd344ea 100644 --- a/src/extension_shield/api/main.py +++ b/src/extension_shield/api/main.py @@ -3016,6 +3016,18 @@ async def generate_pdf_report(extension_id: str) -> Response: raise HTTPException(status_code=500, detail=f"Failed to generate PDF: {str(e)}") +def _can_view_scan_files(request_user_id: str | None, results: dict[str, Any]) -> bool: + """Allow file access for public scans and the owning user for private scans.""" + if not isinstance(results, dict): + return False + if results.get("visibility") != "private": + return True + owner_user_id = results.get("user_id") + if not owner_user_id or not request_user_id: + return False + return str(owner_user_id) == str(request_user_id) + + @app.get("/api/scan/files/{extension_id}") async def get_file_list(extension_id: str, http_request: Request) -> FileListResponse: """ @@ -3038,6 +3050,9 @@ async def get_file_list(extension_id: str, http_request: Request) -> FileListRes if not results: raise HTTPException(status_code=404, detail="Extension not found") + if not _can_view_scan_files(user_id, results): + raise HTTPException(status_code=404, detail="Scan results not found") + extracted_path = results.get("extracted_path") if not extracted_path or not os.path.exists(extracted_path): raise HTTPException(status_code=404, detail="Extracted files not found") @@ -3069,6 +3084,9 @@ async def get_file_content(extension_id: str, file_path: str, http_request: Requ if not results: raise HTTPException(status_code=404, detail="Extension not found") + if not _can_view_scan_files(user_id, results): + raise HTTPException(status_code=404, detail="Scan results not found") + extracted_path = results.get("extracted_path") if not extracted_path: raise HTTPException(status_code=404, detail="Extracted files not found") diff --git a/tests/api/test_private_scan_file_access.py b/tests/api/test_private_scan_file_access.py new file mode 100644 index 0000000..594f590 --- /dev/null +++ b/tests/api/test_private_scan_file_access.py @@ -0,0 +1,90 @@ +""" +Tests for scan file access control. + +Security regression coverage: +- Public scans remain accessible. +- Private scans are hidden from non-owners. +- File list and file content endpoints do not leak private scan existence. +""" + +from pathlib import Path +import tempfile + +import pytest +from fastapi.testclient import TestClient + +from extension_shield.api.main import app, scan_results + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture +def extracted_dir() -> Path: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + (root / "manifest.json").write_text('{"name":"Public Extension"}', encoding="utf-8") + scripts_dir = root / "scripts" + scripts_dir.mkdir() + (scripts_dir / "content.js").write_text("console.log('hello');", encoding="utf-8") + yield root + + +class TestPrivateScanFileAccess: + def test_public_scan_file_list_is_accessible(self, client: TestClient, extracted_dir: Path): + ext_id = "abcdefghijklmnopabcdefghijklmnop" + scan_results[ext_id] = { + "extension_id": ext_id, + "status": "completed", + "visibility": "public", + "user_id": None, + "extracted_path": str(extracted_dir), + } + + try: + response = client.get(f"/api/scan/files/{ext_id}") + + assert response.status_code == 200 + files = response.json()["files"] + assert "manifest.json" in files + assert "scripts/content.js" in files + finally: + scan_results.pop(ext_id, None) + + def test_private_scan_file_list_hidden_from_non_owner(self, client: TestClient, extracted_dir: Path): + ext_id = "bcdefghijklmnopabcdefghijklmnopa" + scan_results[ext_id] = { + "extension_id": ext_id, + "status": "completed", + "visibility": "private", + "user_id": "owner-user-123", + "extracted_path": str(extracted_dir), + } + + try: + response = client.get(f"/api/scan/files/{ext_id}") + + assert response.status_code == 404 + assert response.json()["detail"] == "Scan results not found" + finally: + scan_results.pop(ext_id, None) + + def test_private_scan_file_content_hidden_from_non_owner(self, client: TestClient, extracted_dir: Path): + ext_id = "cdefghijklmnopabcdefghijklmnopab" + scan_results[ext_id] = { + "extension_id": ext_id, + "status": "completed", + "visibility": "private", + "user_id": "owner-user-123", + "extracted_path": str(extracted_dir), + } + + try: + response = client.get(f"/api/scan/file/{ext_id}/scripts/content.js") + + assert response.status_code == 404 + assert response.json()["detail"] == "Scan results not found" + finally: + scan_results.pop(ext_id, None)