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