From 3fa9ed2b91bd884b4a074bc5e26bcf0747dfbb84 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 3 Feb 2026 08:44:07 +0100 Subject: [PATCH] Add authentication to 200+ API endpoints (CVE-2026-25505) Security fix for critical vulnerability (CVSS 9.8) where API endpoints were accessible without authentication when auth was enabled. Changes: - Add RequirePermissionIfAuthEnabled() to all unprotected route files: archives, projects, settings, api_keys, groups, cloud, github_backup, support, notifications, notification_templates, maintenance, filaments, external_links, smart_plugs, discovery, firmware, kprofiles, camera, ams_history, pending_uploads, updates, spoolman, system, print_queue, printers - Keep image-serving endpoints (thumbnails, timelapse, photos, camera streams, icons) unauthenticated since tags cannot send headers - Add backend integration tests for endpoint auth enforcement - Add frontend tests for ownership-based permissions (canModify) Fixes: CVE-2026-25505 --- CHANGELOG.md | 8 + README.md | 1 + backend/app/api/routes/ams_history.py | 5 + backend/app/api/routes/api_keys.py | 13 +- backend/app/api/routes/archives.py | 163 ++++++++++++++--- backend/app/api/routes/camera.py | 31 +++- backend/app/api/routes/cloud.py | 76 ++++++-- backend/app/api/routes/discovery.py | 37 +++- backend/app/api/routes/external_links.py | 20 +- backend/app/api/routes/filaments.py | 29 ++- backend/app/api/routes/firmware.py | 16 +- backend/app/api/routes/github_backup.py | 33 +++- backend/app/api/routes/kprofiles.py | 10 + backend/app/api/routes/maintenance.py | 28 ++- .../app/api/routes/notification_templates.py | 30 ++- backend/app/api/routes/notifications.py | 22 ++- backend/app/api/routes/pending_uploads.py | 26 ++- backend/app/api/routes/print_queue.py | 14 +- backend/app/api/routes/printers.py | 2 + backend/app/api/routes/projects.py | 27 +++ backend/app/api/routes/settings.py | 39 +++- backend/app/api/routes/smart_plugs.py | 74 ++++++-- backend/app/api/routes/spoolman.py | 44 ++++- backend/app/api/routes/support.py | 21 ++- backend/app/api/routes/system.py | 8 +- backend/app/api/routes/updates.py | 22 ++- .../tests/integration/test_endpoint_auth.py | 173 ++++++++++++++++++ .../__tests__/contexts/AuthContext.test.tsx | 95 ++++++++++ 28 files changed, 948 insertions(+), 119 deletions(-) create mode 100644 backend/tests/integration/test_endpoint_auth.py 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