From 0302e5b21aec7470940296edb0127bb2801df636 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Wed, 7 Jan 2026 14:24:44 -0600 Subject: [PATCH] Add offline detection, chunked uploads, and security tests (#9, #10, #12, #15) - Add offline detection with navigator.onLine and auto-pause/resume - Implement chunked upload for files >100MB with localStorage persistence - Add download by artifact ID input field in PackagePage - Add 10 security tests for path traversal and malformed requests - Fix test setup globalThis reference --- .../integration/test_upload_download_api.py | 207 +++++++++ frontend/src/components/DragDropUpload.css | 40 ++ frontend/src/components/DragDropUpload.tsx | 396 ++++++++++++++++-- frontend/src/pages/PackagePage.css | 52 +++ frontend/src/pages/PackagePage.tsx | 29 ++ frontend/src/test/setup.ts | 2 +- 6 files changed, 689 insertions(+), 37 deletions(-) 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