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'
           output: 'trivy-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
       - name: Upload Trivy results to GitHub Security
         uses: github/codeql-action/upload-sarif@v4
@@ -95,6 +96,7 @@ jobs:
           format: 'sarif'
           output: 'trivy-config-results.sarif'
           severity: 'CRITICAL,HIGH,MEDIUM'
+          version: 'v0.69.1'
 
       - name: Upload Trivy config results
         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.
 
 
-## [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
 - **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
   - 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
+- **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
 - **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")
 
         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.write_bytes(audio_content)
 
@@ -1605,8 +1608,11 @@ async def process_timelapse(
         else:
             # Save as new file alongside original
             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 "._- ")
+            # Prevent path traversal
+            if ".." in filename or not filename or filename.startswith("."):
+                filename = f"timelapse_{archive_id}_edited"
             if not filename.endswith(".mp4"):
                 filename += ".mp4"
             output_path = archive_dir / filename
@@ -1833,7 +1839,8 @@ async def get_archive_capabilities(
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     import json
-    import xml.etree.ElementTree as ET
+
+    import defusedxml.ElementTree as ET
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2113,7 +2120,7 @@ async def get_plate_preview(
             plate_num = 1
             if "Metadata/slice_info.config" in names:
                 try:
-                    import xml.etree.ElementTree as ET
+                    import defusedxml.ElementTree as ET
 
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     root = ET.fromstring(slice_content)
@@ -2254,7 +2261,8 @@ async def get_archive_plates(
     """
     import json
     import re
-    import xml.etree.ElementTree as ET
+
+    import defusedxml.ElementTree as ET
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2560,7 +2568,7 @@ async def get_filament_requirements(
         archive_id: The archive ID
         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)
     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 re
-    import xml.etree.ElementTree as ET
     import zipfile
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
@@ -1613,9 +1614,10 @@ async def get_library_file_filament_requirements(
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
     """
-    import xml.etree.ElementTree as ET
     import zipfile
 
+    import defusedxml.ElementTree as ET
+
     # Get the library file
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()

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

@@ -2,11 +2,11 @@
 
 import json
 import logging
-import xml.etree.ElementTree as ET
 import zipfile
 from datetime import datetime
 from pathlib import Path
 
+import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 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)
 
 
+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:
     """Add nested archive/printer/library_file info to response."""
     # 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_thumbnail = item.archive.thumbnail_path
         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:
         response.library_file_name = (
             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
         if not item.archive and item.library_file.file_metadata:
             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:
         response.printer_name = item.printer.name
     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."""
     import io
     import json
-    import xml.etree.ElementTree as ET
     import zipfile
 
+    import defusedxml.ElementTree as ET
+
     # Validate printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     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),
 ):
     """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
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()
@@ -939,6 +943,10 @@ async def delete_attachment(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """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
     result = await db.execute(select(Project).where(Project.id == project_id))
     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
 
 # Application version - single source of truth
-APP_VERSION = "0.1.8b"
+APP_VERSION = "0.1.8"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # 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
 from datetime import datetime
 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.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)
     # These models have varying FTP SSL behavior depending on firmware version
     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
     # Maps IP -> "prot_p" or "prot_c"
@@ -363,15 +366,37 @@ class BambuFTPClient:
 
             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:
                 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}")
             return True
         except ftplib.error_perm as e:
@@ -399,8 +424,24 @@ class BambuFTPClient:
             return False
 
         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
         except Exception:
             return False

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

@@ -3,11 +3,11 @@
 import asyncio
 import json
 import logging
-import xml.etree.ElementTree as ET
 import zipfile
 from datetime import datetime, timedelta
 from pathlib import Path
 
+import defusedxml.ElementTree as ET
 from sqlalchemy import func, select
 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):
         """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
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -250,7 +250,7 @@ class TestPrintableObjectsExtraction:
 
     def test_extract_printable_objects_empty_plate(self):
         """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"?>
         <config>
@@ -272,7 +272,7 @@ class TestPrintableObjectsExtraction:
 
     def test_extract_printable_objects_all_skipped(self):
         """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"?>
         <config>
@@ -299,7 +299,7 @@ class TestThreeMFPlateIndexExtraction:
 
     def test_extract_plate_index_from_slice_info(self):
         """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
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -325,7 +325,7 @@ class TestThreeMFPlateIndexExtraction:
 
     def test_extract_plate_index_plate_1(self):
         """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"?>
         <config>
@@ -402,7 +402,7 @@ class TestThreeMFPlateIndexExtraction:
 
     def test_high_plate_number_extraction(self):
         """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"?>
         <config>
@@ -433,7 +433,7 @@ class TestMultiPlate3MFParsing:
 
     def test_parse_multiple_plates_from_slice_info(self):
         """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
         slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -510,7 +510,7 @@ class TestMultiPlate3MFParsing:
 
     def test_filter_filaments_by_plate_id(self):
         """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"?>
         <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."""
 
-from xml.etree import ElementTree as ET
-
 import pytest
+from defusedxml import ElementTree as ET
 
 
 class TestPlateObjectExtraction:

+ 1 - 0
requirements.txt

@@ -20,6 +20,7 @@ pyftpdlib>=2.0.0
 cryptography>=41.0.0
 
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
+defusedxml>=0.7.0  # Safe XML parsing (prevents XXE attacks)
 
 # Excel Export
 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 -->
     <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">
   </head>
   <body>

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