-
Notifications
You must be signed in to change notification settings - Fork 77
fix(auth): block private scan file access #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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") | ||||||
|
||||||
| raise HTTPException(status_code=404, detail="Scan results not found") | |
| raise HTTPException(status_code=404, detail="Extension not found") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Comment on lines
+56
to
+90
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The endpoint returns different 404 details for a missing scan ("Extension not found") vs an unauthorized private scan ("Scan results not found"). This allows clients to distinguish whether a private scan exists, which undermines the stated goal of not leaking private scan existence. Consider using the same generic 404 response (status + detail) for both the missing-results case and the unauthorized private-scan case on this endpoint.