diff --git a/.gitignore b/.gitignore index 57914d1aa..8a840e6be 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ firmware/ node_modules/ data/ + +# JWT secret file (should be in data dir, but protect project root too) +.jwt_secret diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d4847ae..001c23eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,27 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.7b] - Not released +## [0.1.6.2] - 2026-02-02 + +> **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately. + +### Security +- **Critical: Hardcoded JWT Secret Key** (GHSA-gc24-px2r-5qmf, CWE-321) - Fixed hardcoded JWT secret key that could allow attackers to forge authentication tokens: + - JWT secret now loaded from `JWT_SECRET_KEY` environment variable (recommended for production) + - Falls back to auto-generated `.jwt_secret` file in data directory with secure permissions (0600) + - Generates cryptographically secure 64-byte random secret if neither exists + - **Action Required**: Existing users will need to re-login after upgrading +- **Critical: Missing API Authentication** (GHSA-gc24-px2r-5qmf, CWE-306) - Fixed 77+ API endpoints that lacked authentication checks: + - Added HTTP middleware enforcing authentication on ALL `/api/` routes when auth is enabled + - Only essential public endpoints are exempt (login, auth status, version check, WebSocket) + - All other API calls now require valid JWT token or API key + ### Enhancements +- **Location Filter for Queue** (Issue #220): + - Filter queue jobs by printer location in the Queue page + - "Any {Model}" queue assignments can now specify a target location (e.g., "Any X1C in Workshop") + - Location filter dropdown shows all unique locations from printers and queue items + - Location is saved with queue items and displayed in the queue list - **Ownership-Based Permissions** (Issue #205): - Users can now only update/delete their own items unless they have elevated permissions - Update/delete permissions split into `*_own` and `*_all` variants: @@ -51,11 +71,28 @@ All notable changes to Bambuddy will be documented in this file. - Removed ~2000 lines of legacy JSON-based backup/restore code ### Fixes +- **File Manager permissions not enforced** (Issue #224) - Fixed backend not checking `library:read` permission for File Manager endpoints: + - Added `library:read` permission check to all list/view endpoints (files, folders, stats) + - Added `library:upload` permission check to upload and folder creation endpoints + - Added `queue:create` permission check to add-to-queue endpoint + - Added `printers:control` permission check to direct print endpoint + - Added ownership-based permission checks to file move operation + - Users without `library:read` permission can no longer view files in the File Manager + - Users can now only delete/update their own files unless they have `*_all` permissions +- **JWT secret key not persistent across restarts** - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts +- **Images/thumbnails returning 401 when auth enabled** - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like `` don't send Authorization headers - **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems: - Library now stores relative paths in database for portability - Automatic migration converts existing absolute paths to relative on startup - Thumbnails and files now display correctly after restoring backups - **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled +- **External spool AMS mapping causing "Failed to get AMS mapping table"** (Issue #213) - Fixed external spool `ams_mapping2` slot_id handling that caused AMS mapping failures +- **Filename matching for files with spaces** (Issue #218) - Fixed file detection when filenames contain spaces +- **P2S FTP upload failure** (Issue #218) - Fixed FTP uploads to P2S printers by passing `skip_session_reuse` to ImplicitFTP_TLS +- **Printer deletion freeze** (Issue #214) - Fixed UI freeze when deleting printers, and now allows multiple smart plugs per printer +- **Stack trace exposure in error responses** (CodeQL Alert #68) - Fixed stack traces being exposed in API error responses in archives.py +- **Printer serial numbers exposed in support bundle** (Issue #216) - Sanitized printer serial numbers in support bundle logs for privacy +- **Missing sliced_for_model migration** (Issue #211) - Fixed database migration for `sliced_for_model` column that was missing in some upgrade paths ## [0.1.6-final] - 2026-01-31 diff --git a/README.md b/README.md index f305c2971..912ea921c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ ### ⏰ Scheduling & Automation - Print queue with drag-and-drop - Multi-printer selection (send to multiple printers at once) -- Model-based queue assignment (send to "any X1C" for load balancing) +- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering - Filament validation (only assign to printers with required filaments) - Per-printer AMS mapping (individual slot configuration for print farms) - Scheduled prints (date/time) diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 957e1a396..62334e8c1 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.core.auth import ( - require_auth_if_enabled, require_ownership_permission, require_permission_if_auth_enabled, ) @@ -237,7 +236,11 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", " @router.get("/folders", response_model=list[FolderTreeItem]) @router.get("/folders/", response_model=list[FolderTreeItem]) -async def list_folders(response: Response, db: AsyncSession = Depends(get_db)): +async def list_folders( + response: Response, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Get all folders as a tree structure.""" # Prevent browser caching of folder list response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" @@ -289,7 +292,11 @@ async def list_folders(response: Response, db: AsyncSession = Depends(get_db)): @router.get("/folders/by-project/{project_id}", response_model=list[FolderResponse]) -async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get_db)): +async def get_folders_by_project( + project_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Get all folders linked to a specific project.""" result = await db.execute( select(LibraryFolder, Project.name) @@ -326,7 +333,11 @@ async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get @router.get("/folders/by-archive/{archive_id}", response_model=list[FolderResponse]) -async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get_db)): +async def get_folders_by_archive( + archive_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Get all folders linked to a specific archive.""" result = await db.execute( select(LibraryFolder, PrintArchive.print_name) @@ -364,7 +375,11 @@ async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get @router.post("/folders", response_model=FolderResponse) @router.post("/folders/", response_model=FolderResponse) -async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)): +async def create_folder( + data: FolderCreate, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)), +): """Create a new folder.""" # Verify parent exists if specified if data.parent_id is not None: @@ -415,7 +430,11 @@ async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)): @router.get("/folders/{folder_id}", response_model=FolderResponse) -async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)): +async def get_folder( + folder_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Get a folder by ID.""" result = await db.execute( select(LibraryFolder, Project.name, PrintArchive.print_name) @@ -449,8 +468,17 @@ async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)): @router.put("/folders/{folder_id}", response_model=FolderResponse) -async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = Depends(get_db)): - """Update a folder.""" +async def update_folder( + folder_id: int, + data: FolderUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)), +): + """Update a folder. + + Note: Folders require library:update_all permission since they don't have + ownership tracking. + """ result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id)) folder = result.scalar_one_or_none() @@ -595,6 +623,7 @@ async def list_files( folder_id: int | None = None, include_root: bool = True, db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), ): """List files, optionally filtered by folder. @@ -669,7 +698,7 @@ async def upload_file( folder_id: int | None = None, generate_stl_thumbnails: bool = Query(default=True), db: AsyncSession = Depends(get_db), - current_user: User | None = Depends(require_auth_if_enabled), + current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)), ): """Upload a file to the library.""" try: @@ -805,7 +834,7 @@ async def extract_zip_file( create_folder_from_zip: bool = Query(default=False), generate_stl_thumbnails: bool = Query(default=True), db: AsyncSession = Depends(get_db), - current_user: User | None = Depends(require_auth_if_enabled), + current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)), ): """Upload and extract a ZIP file to the library. @@ -1064,9 +1093,13 @@ async def extract_zip_file( async def batch_generate_stl_thumbnails( request: BatchThumbnailRequest, db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)), ): """Generate thumbnails for STL files in batch. + Note: Requires library:update_all permission since this is a batch operation + that may affect files owned by different users. + Can generate thumbnails for: - Specific file IDs (file_ids) - All STL files in a folder (folder_id) @@ -1188,6 +1221,7 @@ def is_sliced_file(filename: str) -> bool: async def add_files_to_queue( request: AddToQueueRequest, db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)), ): """Add library files to the print queue. @@ -1266,6 +1300,7 @@ async def add_files_to_queue( async def get_library_file_plates( file_id: int, db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), ): """Get available plates from a multi-plate 3MF library file. @@ -1477,6 +1512,7 @@ async def get_library_file_filament_requirements( file_id: int, plate_id: int | None = None, db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), ): """Get filament requirements from a library file. @@ -1599,6 +1635,7 @@ async def print_library_file( printer_id: int, body: FilePrintRequest | None = None, db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)), ): """Print a library file directly. @@ -1786,7 +1823,11 @@ async def print_library_file( @router.get("/files/{file_id}", response_model=FileResponseSchema) -async def get_file(file_id: int, db: AsyncSession = Depends(get_db)): +async def get_file( + file_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Get a file by ID with full details.""" result = await db.execute( select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id) @@ -1961,7 +2002,11 @@ async def delete_file( @router.get("/files/{file_id}/download") -async def download_file(file_id: int, db: AsyncSession = Depends(get_db)): +async def download_file( + file_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Download a file.""" result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) file = result.scalar_one_or_none() @@ -2008,7 +2053,11 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): @router.get("/files/{file_id}/gcode") -async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)): +async def get_gcode( + file_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Get gcode for a file (for preview).""" result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) file = result.scalar_one_or_none() @@ -2046,8 +2095,22 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)): @router.post("/files/move") -async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)): - """Move multiple files to a folder.""" +async def move_files( + data: FileMoveRequest, + db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.LIBRARY_UPDATE_ALL, + Permission.LIBRARY_UPDATE_OWN, + ) + ), +): + """Move multiple files to a folder. + + Files not owned by the user are skipped (unless user has *_all permission). + """ + user, can_modify_all = auth_result + # Verify folder exists if specified if data.folder_id is not None: folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id)) @@ -2056,14 +2119,19 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)): # Update files moved = 0 + skipped = 0 for file_id in data.file_ids: result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) file = result.scalar_one_or_none() if file: + # Ownership check + if not can_modify_all and file.created_by_id != user.id: + skipped += 1 + continue file.folder_id = data.folder_id moved += 1 - return {"status": "success", "moved": moved} + return {"status": "success", "moved": moved, "skipped": skipped} @router.post("/bulk-delete", response_model=BulkDeleteResponse) @@ -2133,7 +2201,10 @@ async def bulk_delete( @router.get("/stats") -async def get_library_stats(db: AsyncSession = Depends(get_db)): +async def get_library_stats( + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), +): """Get library statistics.""" # Total files total_files_result = await db.execute(select(func.count(LibraryFile.id))) diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index d4109a5db..ff8f8c4c1 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -121,6 +121,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: "id": item.id, "printer_id": item.printer_id, "target_model": item.target_model, + "target_location": item.target_location, "required_filament_types": required_filament_types_parsed, "waiting_reason": item.waiting_reason, "archive_id": item.archive_id, @@ -289,6 +290,7 @@ async def add_to_queue( item = PrintQueueItem( printer_id=data.printer_id, target_model=target_model_norm, + target_location=data.target_location, required_filament_types=required_filament_types, archive_id=data.archive_id, library_file_id=data.library_file_id, diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 3f924859f..fa890f1d2 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -595,6 +595,15 @@ async def get_printer_cover( possible_filenames.append(f"{subtask_name}.gcode.3mf") possible_filenames.append(f"{subtask_name}.3mf") + # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) + if " " in subtask_name: + normalized = subtask_name.replace(" ", "_") + if normalized.endswith(".3mf"): + possible_filenames.append(normalized) + else: + possible_filenames.append(f"{normalized}.gcode.3mf") + possible_filenames.append(f"{normalized}.3mf") + # Build list of all remote paths to try remote_paths = [] for filename in possible_filenames: @@ -1574,6 +1583,15 @@ async def get_printable_objects( possible_filenames.append(f"{subtask_name}.gcode.3mf") possible_filenames.append(f"{subtask_name}.3mf") + # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) + if " " in subtask_name: + normalized = subtask_name.replace(" ", "_") + if normalized.endswith(".3mf"): + possible_filenames.append(normalized) + else: + possible_filenames.append(f"{normalized}.gcode.3mf") + possible_filenames.append(f"{normalized}.3mf") + # Download 3MF from printer temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}" temp_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index d72b65091..0386c1879 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,7 +1,10 @@ from __future__ import annotations +import logging +import os import secrets from datetime import datetime, timedelta +from pathlib import Path from typing import Annotated import jwt @@ -19,13 +22,76 @@ from backend.app.models.api_key import APIKey from backend.app.models.settings import Settings from backend.app.models.user import User +logger = logging.getLogger(__name__) + # Password hashing # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +def _get_jwt_secret() -> str: + """Get the JWT secret key from environment, file, or generate a new one. + + Priority: + 1. JWT_SECRET_KEY environment variable + 2. .jwt_secret file in data directory + 3. Generate new random secret and save to file + + Returns: + The JWT secret key + """ + # 1. Check environment variable first + env_secret = os.environ.get("JWT_SECRET_KEY") + if env_secret: + logger.info("Using JWT secret from JWT_SECRET_KEY environment variable") + return env_secret + + # 2. Check for secret file in data directory + # Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory + data_dir_env = os.environ.get("DATA_DIR") + if data_dir_env: + data_dir = Path(data_dir_env) + else: + # Fallback to data/ subdirectory under project root (not project root itself!) + data_dir = Path(__file__).parent.parent.parent.parent / "data" + secret_file = data_dir / ".jwt_secret" + + if secret_file.exists(): + try: + secret = secret_file.read_text().strip() + if secret and len(secret) >= 32: + logger.info("Using JWT secret from %s", secret_file) + return secret + except Exception as e: + logger.warning("Failed to read JWT secret file: %s", e) + + # 3. Generate new random secret + new_secret = secrets.token_urlsafe(64) + + # Try to save it + try: + data_dir.mkdir(parents=True, exist_ok=True) + # Note: CodeQL flags this as "clear-text storage of sensitive information" but this is + # intentional and secure - JWT secrets must be readable by the app, we set 0600 permissions, + # and this is standard practice for self-hosted applications (same as .env files). + secret_file.write_text(new_secret) # nosec B105 - intentional secure storage + # Restrict permissions (owner read/write only) + secret_file.chmod(0o600) + logger.info("Generated new JWT secret and saved to %s", secret_file) + except Exception as e: + logger.warning( + "Could not save JWT secret to file (%s). " + "Secret will be regenerated on restart, invalidating existing tokens. " + "Set JWT_SECRET_KEY environment variable for persistence.", + e, + ) + + return new_secret + + # JWT settings -SECRET_KEY = "bambuddy-secret-key-change-in-production" # TODO: Move to settings/env +SECRET_KEY = _get_jwt_secret() ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days diff --git a/backend/app/core/config.py b/backend/app/core/config.py index adc037119..bd4b3ae9d 100644 --- a/backend/app/core/config.py +++ b/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.7b" +APP_VERSION = "0.1.6.2" GITHUB_REPO = "maziggy/bambuddy" # App directory - where the application is installed (for static files) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 8635bf2f5..6c97bf120 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1044,6 +1044,12 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add target_location column to print_queue for location-based filtering (Issue #220) + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)")) + except Exception: + pass + # Migration: Convert absolute paths to relative paths in library_files table # This ensures backup/restore portability across different installations try: diff --git a/backend/app/main.py b/backend/app/main.py index 5f534411e..32d3279e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1002,6 +1002,13 @@ async def on_print_start(printer_id: int, data: dict): possible_names.append(f"{fname}.gcode.3mf") possible_names.append(f"{fname}.3mf") + # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) + space_variants = [] + for name in possible_names: + if " " in name: + space_variants.append(name.replace(" ", "_")) + possible_names.extend(space_variants) + # Remove duplicates while preserving order seen = set() possible_names = [x for x in possible_names if not (x in seen or seen.add(x))] @@ -1085,7 +1092,10 @@ async def on_print_start(printer_id: int, data: dict): if f.get("is_directory"): continue fname = f.get("name", "") - if fname.endswith(".3mf") and search_term in fname.lower(): + # Normalize both for comparison (spaces and underscores are equivalent) + fname_normalized = fname.lower().replace(" ", "_") + search_normalized = search_term.replace(" ", "_") + if fname.endswith(".3mf") and search_normalized in fname_normalized: logger.info(f"Found matching file in {search_dir}: {fname}") temp_path = app_settings.archive_dir / "temp" / fname temp_path.parent.mkdir(parents=True, exist_ok=True) @@ -2527,6 +2537,148 @@ app = FastAPI( lifespan=lifespan, ) + +# ============================================================================= +# Authentication Middleware - Secures ALL API routes by default +# ============================================================================= +# Public routes that don't require authentication even when auth is enabled +PUBLIC_API_ROUTES = { + # Auth routes needed before/during login + "/api/v1/auth/status", + "/api/v1/auth/login", + "/api/v1/auth/setup", # Needed for initial setup and recovery + # Version check for updates (no sensitive data) + "/api/v1/updates/version", + # Metrics endpoint handles its own prometheus_token authentication + "/api/v1/metrics", +} + +# Route prefixes that are public (for routes with dynamic segments) +PUBLIC_API_PREFIXES = [ + # WebSocket connections handle their own auth + "/api/v1/ws", +] + +# Route patterns that are public (read-only display data) +# These are checked with "in path" - needed because browsers load images/videos +# via and