Root cause
Authentication & access control
Fix browser freeze on print completion with camera stream open
AI-assisted change: Claude
How AI contributed: New attack surfacePublished Feb 2, 2026 in
maziggy/bambuddy
Summary 1. A hardcoded secret key used for signing JWTs is checked into source code 2. ManyAPI routes do not check authentication Details I am using the publicly available docker image at ghcr.io/maziggy/bambuddy 1. Hardcoded JWT Secret Key https://github.com/maziggy/bambuddy/blob/a9bb8ed8239602bf08a9914f85a09eeb2bf13d15/backend/app/core/auth.py#L28 <details> <summary>Copying the Authorization token from a request via browser networking tools into JWT.io confirms the token is signed with this key</summary> <img width="1591" height="937" alt="image" src="https://github.com/user-attachments/assets/fd6e805a-9380-438f-a412-623660fa3f5a" /> </details> Any attacker can: 1. Forge valid JWT tokens for any user 2. Bypass authentication entirely 3. Gain full administrative access to any Bambuddy instance using the default key Steps to Reproduce: 1. Run an instance of BamBuddy 2. Create admin user 3. Forge and use JWT: 2. Most API Routes do not check Auth While investigating the JWT forgery, I noticed that requests without Authorization headers still returned information for many endpoints: Full Script and Output Note: I do not have smart plugs or spoolman set up to verify actual behavior with those endpoints so they are excluded from this script. <details> <summary>Script to test GET endpoints with forged JWT and without any auth</summary> </details> <details> <summary>Script output</summary> </details> While this script only tests the GET endpoints, these vulnerabilities are not exclusive to GET endpoints. The GET endpoints were easiest to script since they generally don't require many parameters, but other methods still appear vulnerable. I manually tested POST /api/v1/api-keys/ and was able to create a new API key with all permissions without auth: yields Impact BamBuddy is vulnerable to unauthorized access and control
The hardcoded JWT secret in backend/app/core/auth.py lets anyone forge a token for any user, and most routes accepted requests with no authentication check at all. The AI-written change added a POST /{printer_id}/debug/simulate-print-complete route that runs the full print-completion path without a permission check. The fix adds RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL) to the printer and camera routes.
Root cause
Fix browser freeze on print completion with camera stream open
AI-assisted change: Claude
Fix
Add authentication to 200+ API endpoints (CVE-2026-25505)
Fix by maziggy · no AI marker found
Case facts
--- a/backend/app/api/routes/camera.py+++ b/backend/app/api/routes/camera.py@@ -2,24 +2,21 @@ import asyncio import logging-import weakref-from typing import AsyncGenerator+from collections.abc import AsyncGenerator -from fastapi import APIRouter, HTTPException, Depends, Request-from fastapi.responses import StreamingResponse, Response-from sqlalchemy.ext.asyncio import AsyncSession+from fastapi import APIRouter, Depends, HTTPException, Request+from fastapi.responses import Response, StreamingResponse from sqlalchemy import select+from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db from backend.app.models.printer import Printer from backend.app.services.camera import (- build_camera_url, capture_camera_frame,- test_camera_connection,- get_ffmpeg_path, get_camera_port,+ get_ffmpeg_path,+ test_camera_connection, )-from backend.app.services.printer_manager import printer_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/printers", tags=["camera"])@@ -27,6 +24,17 @@ router = APIRouter(prefix="/printers", tags=["camera"]) # Track active ffmpeg processes for cleanup _active_streams: dict[str, asyncio.subprocess.Process] = {} +# Store last frame for each printer (for photo capture from active stream)+_last_frames: dict[int, bytes] = {}+++def get_buffered_frame(printer_id: int) -> bytes | None:+ """Get the last buffered frame for a printer from an active stream.++ Returns the JPEG frame data if available, or None if no active stream.+ """+ return _last_frames.get(printer_id)+ async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer: """Get printer by ID or raise 404."""@@ -44,6 +52,7 @@ async def generate_mjpeg_stream( fps: int = 10, stream_id: str | None = None, disconnect_event: asyncio.Event | None = None,+ printer_id: int | None = None, ) -> AsyncGenerator[bytes, None]: """Generate MJPEG stream from printer camera using ffmpeg. @@ -52,11 +61,7 @@ async def generate_mjpeg_stream( ffmpeg = get_ffmpeg_path() if not ffmpeg: logger.error("ffmpeg not found - camera streaming requires ffmpeg")- yield (- b"--frame\r\n"- b"Content-Type: text/plain\r\n\r\n"- b"Error: ffmpeg not installed\r\n"- )+ yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n") return port = get_camera_port(model)@@ -70,14 +75,20 @@ async def generate_mjpeg_stream( # -r: Output framerate cmd = [ ffmpeg,- "-rtsp_transport", "tcp",- "-rtsp_flags", "prefer_tcp",- "-i", camera_url,- "-f", "mjpeg",- "-q:v", "5",- "-r", str(fps),+ "-rtsp_transport",+ "tcp",+ "-rtsp_flags",+ "prefer_tcp",+ "-i",+ camera_url,+ "-f",+ "mjpeg",+ "-q:v",+ "5",+ "-r",+ str(fps), "-an", # No audio- "-" # Output to stdout+ "-", # Output to stdout ] logger.info(f"Starting camera stream for {ip_address} (stream_id={stream_id})")@@ -121,10 +132,7 @@ async def generate_mjpeg_stream( try: # Read chunk from ffmpeg- chunk = await asyncio.wait_for(- process.stdout.read(8192),- timeout=10.0- )+ chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=10.0) if not chunk: logger.warning("Camera stream ended (no more data)")@@ -150,8 +158,12 @@ async def generate_mjpeg_stream( break # Extract complete frame- frame = buffer[:end_idx + 2]- buffer = buffer[end_idx + 2:]+ frame = buffer[: end_idx + 2]+ buffer = buffer[end_idx + 2 :]--- a/backend/app/api/routes/printers.py+++ b/backend/app/api/routes/printers.py@@ -1,41 +1,37 @@-import io import logging import zipfile-from pathlib import Path from fastapi import APIRouter, Depends, HTTPException--logger = logging.getLogger(__name__) from fastapi.responses import Response-from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select+from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.core.database import get_db from backend.app.core.config import settings+from backend.app.core.database import get_db from backend.app.models.printer import Printer from backend.app.models.slot_preset import SlotPresetMapping from backend.app.schemas.printer import (+ AMSTray,+ AMSUnit,+ HMSErrorResponse,+ NozzleInfoResponse, PrinterCreate,- PrinterUpdate, PrinterResponse, PrinterStatus,- HMSErrorResponse,- AMSUnit,- AMSTray,- NozzleInfoResponse,+ PrinterUpdate, PrintOptionsResponse, )-from backend.app.services.printer_manager import printer_manager-from backend.app.services.bambu_mqtt import get_stage_name from backend.app.services.bambu_ftp import (- download_file_try_paths_async,- list_files_async, delete_file_async, download_file_bytes_async,+ download_file_try_paths_async, get_storage_info_async,+ list_files_async, )+from backend.app.services.bambu_mqtt import get_stage_name+from backend.app.services.printer_manager import printer_manager -+logger = logging.getLogger(__name__) router = APIRouter(prefix="/printers", tags=["printers"]) @@ -53,9 +49,7 @@ async def create_printer( ): """Add a new printer.""" # Check if serial number already exists- result = await db.execute(- select(Printer).where(Printer.serial_number == printer_data.serial_number)- )+ result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number)) if result.scalar_one_or_none(): raise HTTPException(400, "Printer with this serial number already exists") @@ -172,20 +166,22 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) tray_uuid = tray_data.get("tray_uuid", "") if tray_uuid in ("", "00000000000000000000000000000000"): tray_uuid = None- trays.append(AMSTray(- id=tray_data.get("id", 0),- tray_color=tray_data.get("tray_color"),- tray_type=tray_data.get("tray_type"),- tray_sub_brands=tray_data.get("tray_sub_brands"),- tray_id_name=tray_data.get("tray_id_name"),- tray_info_idx=tray_data.get("tray_info_idx"),- remain=tray_data.get("remain", 0),- k=tray_data.get("k"),- tag_uid=tag_uid,- tray_uuid=tray_uuid,- nozzle_temp_min=tray_data.get("nozzle_temp_min"),- nozzle_temp_max=tray_data.get("nozzle_temp_max"),- ))+ trays.append(+ AMSTray(+ id=tray_data.get("id", 0),+ tray_color=tray_data.get("tray_color"),+ tray_type=tray_data.get("tray_type"),+ tray_sub_brands=tray_data.get("tray_sub_brands"),+ tray_id_name=tray_data.get("tray_id_name"),+ tray_info_idx=tray_data.get("tray_info_idx"),+ remain=tray_data.get("remain", 0),+ k=tray_data.get("k"),+ tag_uid=tag_uid,+ tray_uuid=tray_uuid,+ nozzle_temp_min=tray_data.get("nozzle_temp_min"),+ nozzle_temp_max=tray_data.get("nozzle_temp_max"),+ )+ ) # Prefer humidity_raw (percentage) over humidity (index 1-5) # humidity_raw is the actual percentage value from the sensor humidity_raw = ams_data.get("humidity_raw")@@ -205,13 +201,15 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) # AMS-HT has 1 tray, regular AMS has 4 trays is_ams_ht = len(trays) == 1 - ams_units.append(AMSUnit(- id=ams_data.get("id", 0),- humidity=humidity_value,- temp=ams_data.get("temp"),- is_ams_ht=is_ams_ht,- tray=trays,- ))+ ams_units.append(+ AMSUnit(+ id=ams_data.get("id", 0),+ humidity=humidity_value,+ temp=ams_data.get("temp"),--- a/backend/app/api/routes/camera.py+++ b/backend/app/api/routes/camera.py@@ -9,8 +9,11 @@ from fastapi.responses import Response, StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.database import get_db+from backend.app.core.permissions import Permission from backend.app.models.printer import Printer+from backend.app.models.user import User from backend.app.services.camera import ( capture_camera_frame, generate_chamber_image_stream,@@ -353,6 +356,8 @@ async def camera_stream( This endpoint returns a multipart MJPEG stream that can be used directly in an <img> tag or video player. + Note: Unauthenticated - loaded via <img> tags which can't send auth headers.+ Uses external camera if configured, otherwise uses built-in camera: - External: MJPEG, RTSP, or HTTP snapshot - A1/P1: Chamber image protocol (port 6000)@@ -476,7 +481,10 @@ async def camera_stream( @router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])-async def stop_camera_stream(printer_id: int):+async def stop_camera_stream(+ printer_id: int,+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),+): """Stop all active camera streams for a printer. This can be called by the frontend when the camera window is closed.@@ -527,6 +535,8 @@ async def camera_snapshot( """Capture a single frame from the printer camera. Returns a JPEG image.++ Note: Unauthenticated - loaded via <img> tags which can't send auth headers. """ import tempfile from pathlib import Path@@ -574,6 +584,7 @@ async def camera_snapshot( async def test_camera( printer_id: int, db: AsyncSession = Depends(get_db),+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): """Test camera connection for a printer. @@ -591,7 +602,10 @@ async def test_camera( @router.get("/{printer_id}/camera/status")-async def camera_status(printer_id: int):+async def camera_status(+ printer_id: int,+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),+): """Get the status of an active camera stream. Returns whether a stream is active and when the last frame was received.@@ -658,6 +672,7 @@ async def test_external_camera( url: str, camera_type: str, db: AsyncSession = Depends(get_db),+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): """Test external camera connection. @@ -684,6 +699,7 @@ async def check_plate_empty( use_external: bool = False, include_debug_image: bool = False, db: AsyncSession = Depends(get_db),+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): """Check if the build plate is empty using camera vision. @@ -791,6 +807,7 @@ async def calibrate_plate_detection( label: str | None = None, use_external: bool = False, db: AsyncSession = Depends(get_db),+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): """Calibrate plate detection by capturing a reference image of the empty plate. @@ -854,6 +871,7 @@ async def delete_plate_calibration( printer_id: int, plate_type: str | None = None, db: AsyncSession = Depends(get_db),+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): """Delete the plate detection calibration for a printer and plate type. @@ -894,6 +912,7 @@ async def get_plate_detection_status( printer_id: int, plate_type: str | None = None, db: AsyncSession = Depends(get_db),+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): """Check plate detection status for a printer and plate type. @@ -937,6 +956,7 @@ async def get_plate_detection_status( async def get_plate_references( printer_id: int, db: AsyncSession = Depends(get_db),+ _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): """Get all calibration references for a printer with metadata. @@ -971,7 +991,10 @@ async def get_reference_thumbnail( index: int, db: AsyncSession = Depends(get_db), ):- """Get thumbnail image for a calibration reference."""+ """Get thumbnail image for a calibration reference.++ Note: Unauthenticated - loaded via <img> tags which can't send auth headers.--- a/backend/app/api/routes/printers.py+++ b/backend/app/api/routes/printers.py@@ -1361,6 +1361,7 @@ async def reset_ams_slot( ams_id: int, tray_id: int, db: AsyncSession = Depends(get_db),+ _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), ): """Reset an AMS slot to empty/unconfigured state. @@ -1403,6 +1404,7 @@ async def reset_ams_slot( async def debug_simulate_print_complete( printer_id: int, db: AsyncSession = Depends(get_db),+ _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), ): """DEBUG: Simulate print completion to test freeze behavior. AI-assisted change 9eea149af2fc6d8bc06a8ad235a8dad23238ce4ca423ecead8149b89a1af84c9 · Fix cc5a69c00d228583edfc20cb6f79822d1426d2e9b61729e4d13a3cb35b3f07da