diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d057f7..3b452d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added reusable `DragDropUpload` component for artifact uploads (#8) + - Drag-and-drop file selection with visual feedback + - Click-to-browse fallback + - Multiple file upload support with queue management + - Real-time progress indicators with speed and ETA + - File type and size validation (configurable) + - Concurrent upload handling (configurable max concurrent) + - Automatic retry with exponential backoff for network errors + - Individual file status (pending, uploading, complete, failed) + - Retry and remove actions per file + - Auto-dismiss success messages after 5 seconds +- Integrated DragDropUpload into PackagePage replacing basic file input (#8) +- Added frontend testing infrastructure with Vitest and React Testing Library (#14) + - Configured Vitest for React/TypeScript with jsdom + - Added 24 unit tests for DragDropUpload component + - Tests cover: rendering, drag-drop events, file validation, upload queue, progress, errors +- Added chunked upload support for large files (#9) + - Files >100MB automatically use chunked upload API (10MB chunks) + - Client-side SHA256 hash computation via Web Crypto API + - localStorage persistence for resume after browser close + - Deduplication check at upload init phase +- Added offline detection and network resilience (#12) + - Automatic pause when browser goes offline + - Auto-resume when connection restored + - Offline banner UI with status message + - XHR abort on network loss to prevent hung requests +- Added download by artifact ID feature (#10) + - Direct artifact ID input field on package page + - Hex-only input validation with character count + - File size and filename displayed in tag list +- Added backend security tests (#15) + - Path traversal prevention tests for upload/download + - Malformed request handling tests + - Checksum validation tests + - 10 new security-focused integration tests - Added download verification with `verify` and `verify_mode` query parameters (#26) - `?verify=true&verify_mode=pre` - Pre-verification: verify before streaming (guaranteed no corrupt data) - `?verify=true&verify_mode=stream` - Streaming verification: verify while streaming (logs error if mismatch) diff --git a/backend/tests/integration/test_upload_download_api.py b/backend/tests/integration/test_upload_download_api.py index dfa25f9..8b83e02 100644 --- a/backend/tests/integration/test_upload_download_api.py +++ b/backend/tests/integration/test_upload_download_api.py @@ -500,3 +500,210 @@ class TestS3StorageVerification: artifact = response.json() assert artifact["id"] == expected_hash assert artifact["ref_count"] == 3 + + +class TestSecurityPathTraversal: + """Tests for path traversal attack prevention. + + Note: Orchard uses content-addressable storage where files are stored by + SHA256 hash, not filename. Filenames are metadata only and never used in + file path construction, so path traversal in filenames is not a security + vulnerability. These tests verify the system handles unusual inputs safely. + """ + + @pytest.mark.integration + def test_path_traversal_in_filename_stored_safely( + self, integration_client, test_package + ): + """Test filenames with path traversal are stored safely (as metadata only).""" + project, package = test_package + content = b"path traversal test content" + expected_hash = compute_sha256(content) + + files = { + "file": ( + "../../../etc/passwd", + io.BytesIO(content), + "application/octet-stream", + ) + } + response = integration_client.post( + f"/api/v1/project/{project}/{package}/upload", + files=files, + data={"tag": "traversal-test"}, + ) + assert response.status_code == 200 + result = response.json() + assert result["artifact_id"] == expected_hash + s3_objects = list_s3_objects_by_hash(expected_hash) + assert len(s3_objects) == 1 + assert ".." not in s3_objects[0] + + @pytest.mark.integration + def test_path_traversal_in_package_name(self, integration_client, test_project): + """Test package names with path traversal sequences are rejected.""" + response = integration_client.get( + f"/api/v1/project/{test_project}/packages/../../../etc/passwd" + ) + assert response.status_code in [400, 404, 422] + + @pytest.mark.integration + def test_path_traversal_in_tag_name(self, integration_client, test_package): + """Test tag names with path traversal are handled safely.""" + project, package = test_package + content = b"tag traversal test" + + files = {"file": ("test.bin", io.BytesIO(content), "application/octet-stream")} + response = integration_client.post( + f"/api/v1/project/{project}/{package}/upload", + files=files, + data={"tag": "../../../etc/passwd"}, + ) + assert response.status_code in [200, 400, 422] + + @pytest.mark.integration + def test_download_path_traversal_in_ref(self, integration_client, test_package): + """Test download ref with path traversal is rejected.""" + project, package = test_package + + response = integration_client.get( + f"/api/v1/project/{project}/{package}/+/../../../etc/passwd" + ) + assert response.status_code in [400, 404, 422] + + @pytest.mark.integration + def test_path_traversal_in_package_name(self, integration_client, test_project): + """Test package names with path traversal sequences are rejected.""" + response = integration_client.get( + f"/api/v1/project/{test_project}/packages/../../../etc/passwd" + ) + assert response.status_code in [400, 404, 422] + + @pytest.mark.integration + def test_path_traversal_in_tag_name(self, integration_client, test_package): + """Test tag names with path traversal are rejected or handled safely.""" + project, package = test_package + content = b"tag traversal test" + + files = {"file": ("test.bin", io.BytesIO(content), "application/octet-stream")} + response = integration_client.post( + f"/api/v1/project/{project}/{package}/upload", + files=files, + data={"tag": "../../../etc/passwd"}, + ) + assert response.status_code in [200, 400, 422] + + @pytest.mark.integration + def test_download_path_traversal_in_ref(self, integration_client, test_package): + """Test download ref with path traversal is rejected.""" + project, package = test_package + + response = integration_client.get( + f"/api/v1/project/{project}/{package}/+/../../../etc/passwd" + ) + assert response.status_code in [400, 404, 422] + + +class TestSecurityMalformedRequests: + """Tests for malformed request handling.""" + + @pytest.mark.integration + def test_upload_missing_file_field(self, integration_client, test_package): + """Test upload without file field returns appropriate error.""" + project, package = test_package + + response = integration_client.post( + f"/api/v1/project/{project}/{package}/upload", + data={"tag": "no-file"}, + ) + assert response.status_code == 422 + + @pytest.mark.integration + def test_upload_null_bytes_in_filename(self, integration_client, test_package): + """Test filename with null bytes is handled safely.""" + project, package = test_package + content = b"null byte test" + + files = { + "file": ("test\x00.bin", io.BytesIO(content), "application/octet-stream") + } + response = integration_client.post( + f"/api/v1/project/{project}/{package}/upload", + files=files, + ) + assert response.status_code in [200, 400, 422] + + @pytest.mark.integration + def test_upload_very_long_filename(self, integration_client, test_package): + """Test very long filename is handled (truncated or rejected).""" + project, package = test_package + content = b"long filename test" + long_filename = "a" * 1000 + ".bin" + + files = { + "file": (long_filename, io.BytesIO(content), "application/octet-stream") + } + response = integration_client.post( + f"/api/v1/project/{project}/{package}/upload", + files=files, + ) + assert response.status_code in [200, 400, 413, 422] + + @pytest.mark.integration + def test_upload_special_characters_in_filename( + self, integration_client, test_package + ): + """Test filenames with special characters are handled safely.""" + project, package = test_package + content = b"special char test" + + special_filenames = [ + "test