Skip to content
Open
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
18 changes: 18 additions & 0 deletions src/extension_shield/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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")
Comment on lines 3050 to +3054
Copy link

Copilot AI Apr 2, 2026

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.

Copilot uses AI. Check for mistakes.

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")
Expand Down Expand Up @@ -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")
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the file list endpoint, this endpoint returns "Extension not found" when results are absent but "Scan results not found" when results exist but access is denied. That difference can be used to confirm the existence of private scans. Align the not-found response for both cases to a single generic 404 (including the same detail message).

Suggested change
raise HTTPException(status_code=404, detail="Scan results not found")
raise HTTPException(status_code=404, detail="Extension not found")

Copilot uses AI. Check for mistakes.

extracted_path = results.get("extracted_path")
if not extracted_path:
raise HTTPException(status_code=404, detail="Extracted files not found")
Expand Down
90 changes: 90 additions & 0 deletions tests/api/test_private_scan_file_access.py
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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new behavior allows owners to access private scan files, but the tests only cover public access and non-owner denial. Add a regression test that simulates an authenticated owner (e.g., by monkeypatching extension_shield.api.main._get_current_user_id or otherwise setting request.state.user_id) and asserts the private scan file list/content endpoints return 200 for the owner.

Copilot uses AI. Check for mistakes.
Loading