diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8dd9e29..53260fdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.7b] - Not released +### Security +- **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8): + - Added authentication to 200+ API endpoints that were previously unprotected + - All route files now use `RequirePermissionIfAuthEnabled()` for permission checks + - Protected endpoints: archives, projects, settings, API keys, groups, cloud, notifications, maintenance, filaments, external links, smart plugs, discovery, firmware, camera, k-profiles, AMS history, pending uploads, updates, spoolman, system, print queue, printers + - Image-serving endpoints (thumbnails, timelapse, photos, camera streams) remain public as they require knowing the resource ID and are loaded via `` tags which cannot send Authorization headers + - Backend integration tests added to verify endpoint authentication enforcement + ### Enhancements - **TOTP Authenticator Support for Bambu Cloud** (Issue #182): - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud diff --git a/README.md b/README.md index 912ea921c..99cb3a585 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ - Group-based permissions (50+ granular permissions) - Default groups: Administrators, Operators, Viewers - JWT tokens with secure password hashing +- Comprehensive API protection (200+ endpoints secured) - User management (create, edit, delete, groups) - User activity tracking (who uploaded archives, library files, queued prints, started prints) diff --git a/backend/app/api/routes/ams_history.py b/backend/app/api/routes/ams_history.py index ffe91e3cf..5896f7dd6 100644 --- a/backend/app/api/routes/ams_history.py +++ b/backend/app/api/routes/ams_history.py @@ -7,8 +7,11 @@ from pydantic import BaseModel from sqlalchemy import and_, func, 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.ams_history import AMSSensorHistory +from backend.app.models.user import User router = APIRouter(prefix="/ams-history", tags=["ams-history"]) @@ -38,6 +41,7 @@ async def get_ams_history( ams_id: int, hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"), db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ), ): """Get AMS sensor history for a specific printer and AMS unit.""" since = datetime.now() - timedelta(hours=hours) @@ -101,6 +105,7 @@ async def delete_old_history( printer_id: int, days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"), db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ), ): """Delete old AMS history data for a printer.""" cutoff = datetime.now() - timedelta(days=days) diff --git a/backend/app/api/routes/api_keys.py b/backend/app/api/routes/api_keys.py index 033376213..8b289a65a 100644 --- a/backend/app/api/routes/api_keys.py +++ b/backend/app/api/routes/api_keys.py @@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.core.auth import generate_api_key +from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key from backend.app.core.database import get_db +from backend.app.core.permissions import Permission from backend.app.models.api_key import APIKey +from backend.app.models.user import User from backend.app.schemas.api_key import ( APIKeyCreate, APIKeyCreateResponse, @@ -20,7 +22,10 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"]) @router.get("/", response_model=list[APIKeyResponse]) -async def list_api_keys(db: AsyncSession = Depends(get_db)): +async def list_api_keys( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ), +): """List all API keys (without full key values).""" result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc())) return list(result.scalars().all()) @@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)): async def create_api_key( data: APIKeyCreate, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE), ): """Create a new API key. @@ -74,6 +80,7 @@ async def create_api_key( async def get_api_key( key_id: int, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ), ): """Get an API key by ID.""" result = await db.execute(select(APIKey).where(APIKey.id == key_id)) @@ -90,6 +97,7 @@ async def update_api_key( key_id: int, data: APIKeyUpdate, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE), ): """Update an API key.""" result = await db.execute(select(APIKey).where(APIKey.id == key_id)) @@ -124,6 +132,7 @@ async def update_api_key( async def delete_api_key( key_id: int, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE), ): """Delete (revoke) an API key.""" result = await db.execute(select(APIKey).where(APIKey.id == key_id)) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index e2e73c844..2c96b6cc9 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -8,7 +8,10 @@ from fastapi.responses import FileResponse, Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission +from backend.app.core.auth import ( + RequirePermissionIfAuthEnabled, + require_ownership_permission, +) from backend.app.core.config import settings from backend.app.core.database import get_db from backend.app.core.permissions import Permission @@ -118,6 +121,7 @@ async def list_archives( limit: int = 50, offset: int = 0, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """List archived prints.""" service = ArchiveService(db) @@ -148,6 +152,7 @@ async def search_archives( limit: int = 50, offset: int = 0, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Full-text search across archives. @@ -229,7 +234,10 @@ async def search_archives( @router.post("/search/rebuild-index") -async def rebuild_search_index(db: AsyncSession = Depends(get_db)): +async def rebuild_search_index( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL), +): """Rebuild the full-text search index from existing archives. Use this if search results seem incomplete or incorrect. @@ -267,6 +275,7 @@ async def analyze_failures( printer_id: int | None = None, project_id: int | None = None, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Analyze failure patterns across prints. @@ -291,6 +300,7 @@ async def analyze_failures( async def compare_archives( archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"), db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Compare multiple archives side by side. @@ -331,6 +341,7 @@ async def export_archives( date_to: str | None = Query(None, description="End date (ISO format)"), search: str | None = None, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Export archives to CSV or Excel format. @@ -393,6 +404,7 @@ async def export_stats( printer_id: int | None = None, project_id: int | None = None, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ), ): """Export statistics summary to CSV or Excel format.""" from fastapi.responses import StreamingResponse @@ -421,7 +433,10 @@ async def export_stats( @router.get("/stats", response_model=ArchiveStats) -async def get_archive_stats(db: AsyncSession = Depends(get_db)): +async def get_archive_stats( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ), +): """Get statistics across all archives.""" # Total counts total_result = await db.execute(select(func.count(PrintArchive.id))) @@ -574,7 +589,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): @router.get("/tags") -async def get_all_tags(db: AsyncSession = Depends(get_db)): +async def get_all_tags( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), +): """List all unique tags with usage counts. Returns a list of tags sorted by count (descending), then by name. @@ -604,6 +622,7 @@ async def rename_tag( tag_name: str, request: Request, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL), ): """Rename a tag across all archives. @@ -646,7 +665,11 @@ async def rename_tag( @router.delete("/tags/{tag_name}") -async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)): +async def delete_tag( + tag_name: str, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL), +): """Delete a tag from all archives. Returns the count of affected archives. @@ -671,7 +694,11 @@ async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)): @router.get("/{archive_id}", response_model=ArchiveResponse) -async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): +async def get_archive( + archive_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), +): """Get a specific archive.""" service = ArchiveService(db) archive = await service.get_archive(archive_id) @@ -694,6 +721,7 @@ async def find_similar_archives( archive_id: int, limit: int = 10, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Find archives with similar settings for comparison. @@ -762,6 +790,7 @@ async def update_archive( async def toggle_favorite( archive_id: int, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN), ): """Toggle favorite status for an archive.""" result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) @@ -776,7 +805,11 @@ async def toggle_favorite( @router.post("/{archive_id}/rescan", response_model=ArchiveResponse) -async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)): +async def rescan_archive( + archive_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL), +): """Rescan the 3MF file and update metadata.""" from backend.app.api.routes.settings import get_setting from backend.app.services.archive import ThreeMFParser @@ -835,7 +868,10 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)): @router.post("/recalculate-costs") -async def recalculate_all_costs(db: AsyncSession = Depends(get_db)): +async def recalculate_all_costs( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL), +): """Recalculate costs for all archives based on filament usage and prices.""" from backend.app.api.routes.settings import get_setting @@ -865,7 +901,10 @@ async def recalculate_all_costs(db: AsyncSession = Depends(get_db)): @router.post("/rescan-all") -async def rescan_all_archives(db: AsyncSession = Depends(get_db)): +async def rescan_all_archives( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL), +): """Rescan all archives and update their metadata.""" from backend.app.services.archive import ThreeMFParser @@ -912,7 +951,11 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)): @router.get("/{archive_id}/duplicates") -async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)): +async def get_archive_duplicates( + archive_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), +): """Get duplicates for a specific archive.""" service = ArchiveService(db) archive = await service.get_archive(archive_id) @@ -930,7 +973,10 @@ async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get @router.post("/backfill-hashes") -async def backfill_content_hashes(db: AsyncSession = Depends(get_db)): +async def backfill_content_hashes( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL), +): """Compute and store content hashes for all archives missing them.""" result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None))) archives = list(result.scalars().all()) @@ -991,6 +1037,7 @@ async def download_archive( archive_id: int, inline: bool = False, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Download the 3MF file.""" service = ArchiveService(db) @@ -1018,6 +1065,7 @@ async def download_archive_with_filename( archive_id: int, filename: str, db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Download the 3MF file with filename in URL (for Bambu Studio protocol).""" service = ArchiveService(db) @@ -1037,8 +1085,14 @@ async def download_archive_with_filename( @router.get("/{archive_id}/thumbnail") -async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)): - """Get the thumbnail image.""" +async def get_thumbnail( + archive_id: int, + db: AsyncSession = Depends(get_db), +): + """Get the thumbnail image. + + Note: Unauthenticated - loaded via tags which can't send auth headers. + """ service = ArchiveService(db) archive = await service.get_archive(archive_id) if not archive or not archive.thumbnail_path: @@ -1062,8 +1116,14 @@ async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)): @router.get("/{archive_id}/timelapse") -async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)): - """Get the timelapse video.""" +async def get_timelapse( + archive_id: int, + db: AsyncSession = Depends(get_db), +): + """Get the timelapse video. + + Note: Unauthenticated - loaded via