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