Browse Source

Merge branch 'maziggy:main' into feature/accurate-usage-tracking

BambuMan 3 months ago
parent
commit
0a39b12064

+ 2 - 0
.github/workflows/security.yml

@@ -79,6 +79,7 @@ jobs:
           format: 'sarif'
           format: 'sarif'
           output: 'trivy-results.sarif'
           output: 'trivy-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
 
       - name: Upload Trivy results to GitHub Security
       - name: Upload Trivy results to GitHub Security
         uses: github/codeql-action/upload-sarif@v4
         uses: github/codeql-action/upload-sarif@v4
@@ -95,6 +96,7 @@ jobs:
           format: 'sarif'
           format: 'sarif'
           output: 'trivy-config-results.sarif'
           output: 'trivy-config-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
 
       - name: Upload Trivy config results
       - name: Upload Trivy config results
         uses: github/codeql-action/upload-sarif@v4
         uses: github/codeql-action/upload-sarif@v4

+ 43 - 1
CHANGELOG.md

@@ -3,7 +3,24 @@
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
 
 
-## [0.1.8b] - Not released
+## [0.1.8] - Not released
+
+### Security
+- **XML External Entity (XXE) Prevention**:
+  - Replaced `xml.etree.ElementTree` with `defusedxml` across all 3MF parsing code
+  - Prevents XXE attacks through malicious 3MF files
+  - Detected by Bandit B314 security scanner
+- **Path Injection Vulnerabilities Fixed**:
+  - Added path traversal validation to project attachment endpoints
+  - Strengthened filename sanitization in timelapse processing
+  - Prevents directory traversal attacks via `../` sequences
+  - Detected by CodeQL security scanner
+- **Security Scanning in CI/CD**:
+  - Added Bandit (Python security analyzer) with SARIF upload to GitHub Security
+  - Added Trivy (container/IaC scanner) for Docker image and Dockerfile analysis
+  - Added pip-audit and npm-audit for dependency vulnerability scanning
+  - Automatic GitHub issue creation for detected vulnerabilities
+  - Security scan results visible in GitHub Security tab
 
 
 ### Enhanced
 ### Enhanced
 - **3D Model Viewer Improvements** (PR #262):
 - **3D Model Viewer Improvements** (PR #262):
@@ -53,6 +70,31 @@ All notable changes to Bambuddy will be documented in this file.
   - Previously used `find()` which always returned the first match regardless of color
   - Previously used `find()` which always returned the first match regardless of color
   - Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
   - Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
   - Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
   - Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
+- **A1/A1 Mini FTP Upload Failures** (Issue #271):
+  - Fixed FTP uploads hanging/timing out on A1 and A1 Mini printers
+  - Replaced `storbinary()` with manual chunked transfer using `transfercmd()`
+  - A1's FTP server has issues with Python's `storbinary()` waiting for completion response
+  - Uses 1MB chunks with explicit 120s socket timeout for reliable transfers
+  - Works for all printer models (X1C, P1S, P1P, A1, A1 Mini)
+- **P1S/P1P FTP Upload Failures**:
+  - Fixed FTP uploads failing with EOFError on P1S and P1P printers
+  - These printers use vsFTPd which requires SSL session reuse on data channel
+  - Removed P1S/P1P from skip-session-reuse list (they were incorrectly added)
+- **FTP Auto-Detection for A1 Printers**:
+  - Automatically detects working FTP mode (prot_p vs prot_c) for A1/A1 Mini
+  - Tries encrypted data channel first, falls back to clear if needed
+  - Caches working mode per printer IP to avoid repeated detection
+- **Safari Camera Stream Failing**:
+  - Fixed camera streams not loading in Safari due to Service Worker error
+  - Safari has stricter Service Worker scope requirements
+- **Queue Print Time for Multi-Plate Files** (PR #274):
+  - Fixed print time showing total for all plates instead of selected plate
+  - Now extracts per-plate print time from 3MF slice_info.config
+  - Contributed by MisterBeardy
+- **Docker Permissions**:
+  - Added user directive to docker-compose.yml using PUID/PGID environment variables
+  - Allows container to run as host user, fixing permission issues with bind-mounted volumes
+  - Usage: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`
 
 
 ### Added
 ### Added
 - **Windows Portable Launcher** (contributed by nmori):
 - **Windows Portable Launcher** (contributed by nmori):

+ 14 - 6
backend/app/api/routes/archives.py

@@ -1590,7 +1590,10 @@ async def process_timelapse(
             raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
             raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
 
 
         audio_content = await audio.read()
         audio_content = await audio.read()
-        suffix = Path(audio.filename).suffix
+        # Extract and validate suffix to prevent path injection
+        suffix = Path(audio.filename).suffix.lower()
+        if suffix not in (".mp3", ".wav", ".m4a", ".aac", ".ogg"):
+            raise HTTPException(400, "Invalid audio file extension")
         audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
         audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
         audio_temp_path.write_bytes(audio_content)
         audio_temp_path.write_bytes(audio_content)
 
 
@@ -1605,8 +1608,11 @@ async def process_timelapse(
         else:
         else:
             # Save as new file alongside original
             # Save as new file alongside original
             filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
             filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
-            # Sanitize filename
+            # Sanitize filename - remove path separators and traversal sequences
             filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
             filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
+            # Prevent path traversal
+            if ".." in filename or not filename or filename.startswith("."):
+                filename = f"timelapse_{archive_id}_edited"
             if not filename.endswith(".mp4"):
             if not filename.endswith(".mp4"):
                 filename += ".mp4"
                 filename += ".mp4"
             output_path = archive_dir / filename
             output_path = archive_dir / filename
@@ -1833,7 +1839,8 @@ async def get_archive_capabilities(
 ):
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     """Check what viewing capabilities are available for this 3MF file."""
     import json
     import json
-    import xml.etree.ElementTree as ET
+
+    import defusedxml.ElementTree as ET
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
@@ -2113,7 +2120,7 @@ async def get_plate_preview(
             plate_num = 1
             plate_num = 1
             if "Metadata/slice_info.config" in names:
             if "Metadata/slice_info.config" in names:
                 try:
                 try:
-                    import xml.etree.ElementTree as ET
+                    import defusedxml.ElementTree as ET
 
 
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     root = ET.fromstring(slice_content)
                     root = ET.fromstring(slice_content)
@@ -2254,7 +2261,8 @@ async def get_archive_plates(
     """
     """
     import json
     import json
     import re
     import re
-    import xml.etree.ElementTree as ET
+
+    import defusedxml.ElementTree as ET
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
@@ -2560,7 +2568,7 @@ async def get_filament_requirements(
         archive_id: The archive ID
         archive_id: The archive ID
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
     """
     """
-    import xml.etree.ElementTree as ET
+    import defusedxml.ElementTree as ET
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)

+ 4 - 2
backend/app/api/routes/library.py

@@ -1312,9 +1312,10 @@ async def get_library_file_plates(
     """
     """
     import json
     import json
     import re
     import re
-    import xml.etree.ElementTree as ET
     import zipfile
     import zipfile
 
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
     lib_file = result.scalar_one_or_none()
@@ -1613,9 +1614,10 @@ async def get_library_file_filament_requirements(
         file_id: The library file ID
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
         plate_id: Optional plate index to get filaments for a specific plate
     """
     """
-    import xml.etree.ElementTree as ET
     import zipfile
     import zipfile
 
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
     lib_file = result.scalar_one_or_none()

+ 66 - 1
backend/app/api/routes/print_queue.py

@@ -2,11 +2,11 @@
 
 
 import json
 import json
 import logging
 import logging
-import xml.etree.ElementTree as ET
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
+import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -98,6 +98,58 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
     return sorted(types)
     return sorted(types)
 
 
 
 
+def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
+    """Extract print time (prediction) from a 3MF file.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
+
+    Returns:
+        Print time in seconds, or None if not found
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            if plate_id is not None:
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for meta in plate_elem.findall("metadata"):
+                            if meta.get("key") == "prediction":
+                                try:
+                                    return int(meta.get("value", "0"))
+                                except ValueError:
+                                    return None
+                        break
+            else:
+                plate_elem = root.find(".//plate")
+                if plate_elem is not None:
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "prediction":
+                            try:
+                                return int(meta.get("value", "0"))
+                            except ValueError:
+                                return None
+    except Exception as e:
+        logger.warning(f"Failed to extract print time from {file_path}: {e}")
+
+    return None
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -153,6 +205,12 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         response.print_time_seconds = item.archive.print_time_seconds
+        if item.plate_id:
+            archive_path = settings.base_dir / item.archive.file_path
+            if archive_path.exists():
+                plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
+                if plate_time is not None:
+                    response.print_time_seconds = plate_time
     if item.library_file:
     if item.library_file:
         response.library_file_name = (
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -163,6 +221,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # Get print time from library file metadata if no archive
         # Get print time from library file metadata if no archive
         if not item.archive and item.library_file.file_metadata:
         if not item.archive and item.library_file.file_metadata:
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
+        if item.plate_id:
+            lib_path = Path(item.library_file.file_path)
+            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
+            if library_file_path.exists():
+                plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
+                if plate_time is not None:
+                    response.print_time_seconds = plate_time
     if item.printer:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response

+ 2 - 1
backend/app/api/routes/printers.py

@@ -852,9 +852,10 @@ async def get_printer_file_plates(
     """Get available plates from a multi-plate 3MF file stored on a printer."""
     """Get available plates from a multi-plate 3MF file stored on a printer."""
     import io
     import io
     import json
     import json
-    import xml.etree.ElementTree as ET
     import zipfile
     import zipfile
 
 
+    import defusedxml.ElementTree as ET
+
     # Validate printer
     # Validate printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()

+ 8 - 0
backend/app/api/routes/projects.py

@@ -907,6 +907,10 @@ async def download_attachment(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """Download an attachment from a project."""
     """Download an attachment from a project."""
+    # Validate filename to prevent path traversal
+    if "/" in filename or "\\" in filename or ".." in filename or not filename:
+        raise HTTPException(status_code=400, detail="Invalid filename")
+
     # Verify project exists
     # Verify project exists
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()
     project = result.scalar_one_or_none()
@@ -939,6 +943,10 @@ async def delete_attachment(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Delete an attachment from a project."""
     """Delete an attachment from a project."""
+    # Validate filename to prevent path traversal
+    if "/" in filename or "\\" in filename or ".." in filename or not filename:
+        raise HTTPException(status_code=400, detail="Invalid filename")
+
     # Verify project exists
     # Verify project exists
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()
     project = result.scalar_one_or_none()

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.8b"
+APP_VERSION = "0.1.8"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # App directory - where the application is installed (for static files)

+ 1 - 1
backend/app/services/archive.py

@@ -6,8 +6,8 @@ import shutil
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
-from xml.etree import ElementTree as ET
 
 
+from defusedxml import ElementTree as ET
 from sqlalchemy import and_, or_, select
 from sqlalchemy import and_, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 

+ 50 - 9
backend/app/services/bambu_ftp.py

@@ -80,6 +80,9 @@ class BambuFTPClient:
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # These models have varying FTP SSL behavior depending on firmware version
     # These models have varying FTP SSL behavior depending on firmware version
     A1_MODELS = ("A1", "A1 Mini")
     A1_MODELS = ("A1", "A1 Mini")
+    # Chunk size for manual upload transfer (1MB)
+    # Larger chunks reduce overhead and work better with A1 printers
+    CHUNK_SIZE = 1024 * 1024
 
 
     # Cache for working FTP modes per printer IP
     # Cache for working FTP modes per printer IP
     # Maps IP -> "prot_p" or "prot_c"
     # Maps IP -> "prot_p" or "prot_c"
@@ -363,15 +366,37 @@ class BambuFTPClient:
 
 
             uploaded = 0
             uploaded = 0
 
 
-            def on_block(block: bytes):
-                nonlocal uploaded
-                uploaded += len(block)
-                if progress_callback:
-                    progress_callback(uploaded, file_size)
-
+            # Use manual transfer instead of storbinary() for A1 compatibility
+            # A1 printers have issues with storbinary's voidresp() hanging after transfer
             with open(local_path, "rb") as f:
             with open(local_path, "rb") as f:
                 logger.debug(f"FTP STOR command starting for {remote_path}")
                 logger.debug(f"FTP STOR command starting for {remote_path}")
-                self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
+                conn = self._ftp.transfercmd(f"STOR {remote_path}")
+
+                # Set explicit socket options for reliable transfer
+                conn.setblocking(True)
+                conn.settimeout(120)  # 2 minute timeout per chunk
+
+                try:
+                    while True:
+                        chunk = f.read(self.CHUNK_SIZE)
+                        if not chunk:
+                            logger.debug("FTP upload: final chunk reached")
+                            break
+
+                        conn.sendall(chunk)
+                        uploaded += len(chunk)
+                        logger.debug(f"FTP upload progress: {uploaded}/{file_size} bytes")
+
+                        if progress_callback:
+                            progress_callback(uploaded, file_size)
+
+                except OSError as e:
+                    logger.error(f"FTP connection lost during upload: {e}")
+                    conn.close()
+                    raise
+
+                conn.close()
+
             logger.info(f"FTP upload complete: {remote_path}")
             logger.info(f"FTP upload complete: {remote_path}")
             return True
             return True
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:
@@ -399,8 +424,24 @@ class BambuFTPClient:
             return False
             return False
 
 
         try:
         try:
-            buffer = BytesIO(data)
-            self._ftp.storbinary(f"STOR {remote_path}", buffer)
+            # Use manual transfer instead of storbinary() for A1 compatibility
+            conn = self._ftp.transfercmd(f"STOR {remote_path}")
+            conn.setblocking(True)
+            conn.settimeout(120)
+
+            try:
+                # Send data in chunks
+                offset = 0
+                while offset < len(data):
+                    chunk = data[offset : offset + self.CHUNK_SIZE]
+                    conn.sendall(chunk)
+                    offset += len(chunk)
+            except OSError as e:
+                logger.error(f"FTP connection lost during upload_bytes: {e}")
+                conn.close()
+                raise
+
+            conn.close()
             return True
             return True
         except Exception:
         except Exception:
             return False
             return False

+ 1 - 1
backend/app/services/print_scheduler.py

@@ -3,11 +3,11 @@
 import asyncio
 import asyncio
 import json
 import json
 import logging
 import logging
-import xml.etree.ElementTree as ET
 import zipfile
 import zipfile
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 
 
+import defusedxml.ElementTree as ET
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 

+ 8 - 8
backend/tests/unit/services/test_archive_service.py

@@ -221,7 +221,7 @@ class TestPrintableObjectsExtraction:
 
 
     def test_extract_printable_objects_from_slice_info(self):
     def test_extract_printable_objects_from_slice_info(self):
         """Test parsing printable objects from slice_info.config XML."""
         """Test parsing printable objects from slice_info.config XML."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         # Example slice_info.config content with 4 objects
         # Example slice_info.config content with 4 objects
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -250,7 +250,7 @@ class TestPrintableObjectsExtraction:
 
 
     def test_extract_printable_objects_empty_plate(self):
     def test_extract_printable_objects_empty_plate(self):
         """Test handling plate with no objects."""
         """Test handling plate with no objects."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
         <config>
@@ -272,7 +272,7 @@ class TestPrintableObjectsExtraction:
 
 
     def test_extract_printable_objects_all_skipped(self):
     def test_extract_printable_objects_all_skipped(self):
         """Test handling plate where all objects are skipped."""
         """Test handling plate where all objects are skipped."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
         <config>
@@ -299,7 +299,7 @@ class TestThreeMFPlateIndexExtraction:
 
 
     def test_extract_plate_index_from_slice_info(self):
     def test_extract_plate_index_from_slice_info(self):
         """Test parsing plate index from slice_info.config metadata."""
         """Test parsing plate index from slice_info.config metadata."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         # Single-plate export from plate 5 of a multi-plate project
         # Single-plate export from plate 5 of a multi-plate project
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -325,7 +325,7 @@ class TestThreeMFPlateIndexExtraction:
 
 
     def test_extract_plate_index_plate_1(self):
     def test_extract_plate_index_plate_1(self):
         """Test parsing plate index when it's plate 1."""
         """Test parsing plate index when it's plate 1."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
         <config>
@@ -402,7 +402,7 @@ class TestThreeMFPlateIndexExtraction:
 
 
     def test_high_plate_number_extraction(self):
     def test_high_plate_number_extraction(self):
         """Test extracting high plate numbers (e.g., plate 28)."""
         """Test extracting high plate numbers (e.g., plate 28)."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
         <config>
@@ -433,7 +433,7 @@ class TestMultiPlate3MFParsing:
 
 
     def test_parse_multiple_plates_from_slice_info(self):
     def test_parse_multiple_plates_from_slice_info(self):
         """Test parsing multiple plates from slice_info.config."""
         """Test parsing multiple plates from slice_info.config."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         # Multi-plate 3MF with 3 plates
         # Multi-plate 3MF with 3 plates
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -510,7 +510,7 @@ class TestMultiPlate3MFParsing:
 
 
     def test_filter_filaments_by_plate_id(self):
     def test_filter_filaments_by_plate_id(self):
         """Test filtering filaments for a specific plate."""
         """Test filtering filaments for a specific plate."""
-        from xml.etree import ElementTree as ET
+        from defusedxml import ElementTree as ET
 
 
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
         <config>
         <config>

+ 1 - 2
backend/tests/unit/test_plate_object_extraction.py

@@ -1,8 +1,7 @@
 """Unit tests for plate object extraction from 3MF model_settings.config."""
 """Unit tests for plate object extraction from 3MF model_settings.config."""
 
 
-from xml.etree import ElementTree as ET
-
 import pytest
 import pytest
+from defusedxml import ElementTree as ET
 
 
 
 
 class TestPlateObjectExtraction:
 class TestPlateObjectExtraction:

+ 1 - 0
requirements.txt

@@ -20,6 +20,7 @@ pyftpdlib>=2.0.0
 cryptography>=41.0.0
 cryptography>=41.0.0
 
 
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
+defusedxml>=0.7.0  # Safe XML parsing (prevents XXE attacks)
 
 
 # Excel Export
 # Excel Export
 openpyxl>=3.1.0
 openpyxl>=3.1.0

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CwIN-fI6.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Dc108DF_.js"></script>
+    <script type="module" crossorigin src="/assets/index-CwIN-fI6.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CosC5iN4.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CosC5iN4.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff