Explorar el Código

fix(security): WebSocket auth gate + audit-driven hardening sweep

maziggy hace 2 días
padre
commit
b7d7c82501
Se han modificado 32 ficheros con 1325 adiciones y 140 borrados
  1. 19 0
      .env.example
  2. 0 0
      CHANGELOG.md
  3. 1 1
      README.md
  4. 79 5
      SECURITY.md
  5. 39 15
      backend/app/api/routes/auth.py
  6. 117 19
      backend/app/api/routes/library.py
  7. 4 1
      backend/app/api/routes/metrics.py
  8. 11 3
      backend/app/api/routes/settings.py
  9. 78 6
      backend/app/api/routes/websocket.py
  10. 150 11
      backend/app/core/auth.py
  11. 37 6
      backend/tests/integration/test_external_folders_api.py
  12. 115 0
      backend/tests/unit/test_no_fail_open_in_auth.py
  13. 158 0
      backend/tests/unit/test_no_hardcoded_secrets.py
  14. 226 0
      backend/tests/unit/test_route_auth_coverage.py
  15. 18 0
      docker-compose.yml
  16. 8 2
      frontend/src/App.tsx
  17. 8 5
      frontend/src/__tests__/contexts/AuthContext.test.tsx
  18. 6 6
      frontend/src/__tests__/contexts/ColorCatalogContext.test.tsx
  19. 17 1
      frontend/src/__tests__/contexts/ThemeContext.test.tsx
  20. 64 22
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  21. 4 4
      frontend/src/__tests__/pages/CameraPage.test.tsx
  22. 6 6
      frontend/src/__tests__/pages/GroupEditPage.test.tsx
  23. 10 7
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  24. 46 0
      frontend/src/__tests__/setup.ts
  25. 9 4
      frontend/src/__tests__/utils.tsx
  26. 6 0
      frontend/src/api/client.ts
  27. 19 2
      frontend/src/contexts/ThemeContext.tsx
  28. 9 2
      frontend/src/hooks/useCameraStreamToken.ts
  29. 28 3
      frontend/src/hooks/useWebSocket.ts
  30. 32 8
      frontend/src/pages/StreamOverlayPage.tsx
  31. 0 0
      static/assets/index-BeiuHSbR.js
  32. 1 1
      static/index.html

+ 19 - 0
.env.example

@@ -36,3 +36,22 @@ LOG_TO_FILE=true
 # also store the value separately (otherwise an encrypted backup cannot be
 # restored after key loss).
 # MFA_ENCRYPTION_KEY=
+
+# External library folders (GHSA-r2qv follow-up) — colon-separated list of
+# host paths that users are permitted to register as external library
+# folders via Settings → Library → "Add external folder".
+#
+# Empty (the default) means the external-folder feature is DISABLED:
+# attempts to register one return HTTP 400. Set this to one or more
+# absolute paths to opt in. Paths that fall inside Bambuddy's own
+# DATA_DIR / LOG_DIR / static dir are always rejected regardless of
+# this value.
+#
+# Example for a single NAS mount:
+#   BAMBUDDY_EXTERNAL_ROOTS=/mnt/nas/3d-prints
+# Example for two roots:
+#   BAMBUDDY_EXTERNAL_ROOTS=/mnt/nas/3d-prints:/srv/library
+#
+# In Docker, also bind-mount the host path into the container at the same
+# location (see docker-compose.yml for the matching volume snippet).
+# BAMBUDDY_EXTERNAL_ROOTS=

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 1 - 1
README.md

@@ -192,7 +192,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ### 📁 File Manager (Library)
 - Upload and organize sliced files (3MF, gcode, STL)
-- **External folder mounting** - Mount host directories (NAS, USB, network shares) without copying files
+- **External folder mounting** - Mount host directories (NAS, USB, network shares) without copying files. Operator-controlled via the `BAMBUDDY_EXTERNAL_ROOTS` env var (colon-separated allowlist of host paths users are permitted to register; empty by default to disable the feature). See [Docker → External library folders](https://wiki.bambuddy.cool/getting-started/docker/#external-library-folders-bambuddy_external_roots).
 - **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - ZIP file extraction with folder structure preservation
 - Option to create folder from ZIP filename

+ 79 - 5
SECURITY.md

@@ -81,11 +81,85 @@ The following are **out of scope**:
 - Denial of service (DoS) attacks
 - Issues requiring physical access to the server
 
-## Acknowledgments
-
-We thank the following individuals for responsibly disclosing security issues:
-
-*No security issues have been reported yet.*
+## Bambuddy Security Stance
+
+The following rules apply to every PR that touches authentication,
+authorization, permission gating, secret handling, or any code that
+decides whether to allow or deny an action. They are not aspirational —
+each one is enforced by a CI test that fails the build on violation.
+
+### 1. Default-deny, allowlist over denylist
+
+At any security boundary, the safe default is to deny and the
+exceptions are listed explicitly. Denylists fail open on growth — every
+new resource added to the codebase is implicitly granted access until
+someone remembers to deny it. Allowlists fail closed: an unmapped new
+resource gets a 403, which is loud and recoverable.
+
+Concretely:
+
+- `_APIKEY_SCOPE_BY_PERMISSION` in `backend/app/core/auth.py` is the
+  load-bearing API-key authorization map. Every `Permission` enum value
+  must be either present here with a scope flag, or present in
+  `_APIKEY_DENIED_PERMISSIONS`. Unmapped permissions return 403.
+- Route auth dependencies are explicit, not implicit. A route without a
+  `Depends(require_*)` decorator must be listed in the route-audit
+  `PUBLIC_ROUTES` allowlist with a justification comment, or CI fails.
+
+### 2. Fail-closed in auth code
+
+No `except Exception:` (or bare `except:`) in authentication,
+authorization, or permission code may return a permissive value
+(`None`, `True`, an admin user, an empty filter that lets everything
+through, etc.). The catch-all either re-raises or returns a denial.
+This is CWE-636 "Not Failing Securely" — see
+<https://cwe.mitre.org/data/definitions/636.html>.
+
+The lint scope is `backend/app/core/auth.py`,
+`backend/app/core/permissions.py`,
+`backend/app/api/routes/auth*.py`. Any `except Exception:` block in
+those files must be tagged `# SEC-AUTH-EXC: <reason>` on the same
+line; CI fails otherwise. (We use a standalone marker rather than
+`# noqa: ...` because ruff reserves the latter syntax for its own
+error codes.)
+
+### 3. No hardcoded fallback secrets
+
+Production secrets (JWT signing keys, encryption keys, OAuth client
+secrets, API tokens) have no string-literal fallback in source. The
+codebase reads them from env vars or generates them on first run; if a
+secret is missing AND cannot be generated, the app refuses to start
+rather than booting with a known value. CI greps the source for
+`-change-in-production`-shaped strings and fails on any hit.
+
+### 4. Negative-path tests required for any auth change
+
+Any PR that adds or modifies an auth dependency, permission check, or
+scope flag includes tests for the negative paths:
+
+- "No credentials → 401"
+- "Wrong credentials → 401"
+- "Right credentials, wrong scope → 403"
+- "Expired / revoked credentials → 401"
+
+A test asserting the happy path passes is necessary but not sufficient.
+The failure modes are where the vulnerabilities live. The structural
+backstops above catch *categories* of regression; the negative-path
+tests catch *specific* regressions in the new code.
+
+### Where these rules live in the codebase
+
+| Rule | Enforcement | Location |
+|------|-------------|----------|
+| 1. Allowlist over denylist (Permission) | `test_every_permission_has_a_classification` | `backend/tests/integration/test_auth_apikey_rbac.py` |
+| 1. Allowlist over denylist (routes) | `test_routes_have_explicit_auth_deps` | `backend/tests/unit/test_route_auth_coverage.py` |
+| 2. Fail-closed in auth code | `test_no_fail_open_in_auth_modules` | `backend/tests/unit/test_no_fail_open_in_auth.py` |
+| 3. No hardcoded fallback secrets | `test_no_hardcoded_secrets` | `backend/tests/unit/test_no_hardcoded_secrets.py` |
+| 4. Negative-path tests required | Reviewer responsibility (no automated CI gate yet) | PR review |
+
+If you are adding a CI rule, update this table. If you are removing a
+CI rule, you are removing a security backstop and the PR description
+must explain why.
 
 ---
 

+ 39 - 15
backend/app/api/routes/auth.py

@@ -25,6 +25,7 @@ from backend.app.core.auth import (
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
+    create_websocket_token,
     get_current_active_user,
     get_password_hash,
     get_user_by_email,
@@ -273,7 +274,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                     db.add(admin_user)
                     logger.info("Admin user added to session: %s", request.admin_username)
                     admin_created = True
-                except Exception as e:
+                except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); no user is created on error
                     await db.rollback()
                     logger.error("Failed to create admin user: %s", e, exc_info=True)
                     raise HTTPException(
@@ -294,7 +295,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
     except HTTPException:
         raise
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); setup state stays unchanged
         logger.error("Setup error: %s", e, exc_info=True)
         await db.rollback()
         raise HTTPException(
@@ -339,7 +340,7 @@ async def disable_auth(
         await db.commit()
         logger.info("Authentication disabled by admin user: %s", user.username)
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); auth_enabled stays at its prior value
         await db.rollback()
         logger.error("Failed to disable authentication: %s", e, exc_info=True)
         raise HTTPException(
@@ -408,7 +409,7 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
                     if user and ldap_user:
                         # Update email and group mappings on each login
                         await _sync_ldap_user(db, user, ldap_user, ldap_config)
-        except Exception as e:
+        except Exception as e:  # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant)
             import logging
 
             logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
@@ -505,6 +506,29 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     )
 
 
+@router.post("/ws-token")
+async def mint_websocket_token(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
+):
+    """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
+
+    The WebSocket endpoint cannot read ``Authorization`` headers from
+    browsers (the WebSocket handshake does not let JS attach custom
+    headers), so we use the same opaque-token-in-query-param pattern
+    as ``/camera/stream`` — the token is minted here behind the standard
+    permission gate, then appended as ``?token=<value>`` on the
+    ``ws://...`` URL. The WebSocket endpoint validates it *before*
+    calling ``websocket.accept()``.
+
+    Returns ``{"token": <opaque string>}``. The token is valid for 60
+    minutes; the SPA refreshes it on reconnect if expired. API keys can
+    mint tokens too — their scope flags decide whether ``WEBSOCKET_CONNECT``
+    passes via the standard allowlist (``can_read_status`` covers it).
+    """
+    username = current_user.username if current_user is not None else None
+    return {"token": await create_websocket_token(username)}
+
+
 @router.get("/me", response_model=UserResponse)
 async def get_current_user_info(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
@@ -619,7 +643,7 @@ async def logout(
                 expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
                 try:
                     await revoke_jti(jti, expires_at, username)
-                except Exception as exc:
+                except Exception as exc:  # SEC-AUTH-EXC: JTI-revoke failure on logout is logged only; logout removes access, never grants it (token stays valid until natural expiry — degraded but never escalation)
                     _logger.error("Failed to revoke JTI on logout for user %s: %s", username, exc)
         except PyJWTError:
             client_ip = _get_client_ip(raw_request)
@@ -664,7 +688,7 @@ async def test_smtp_connection(
 
         logger.info(f"Test email sent successfully to {test_request.test_recipient}")
         return TestSMTPResponse(success=True, message="Test email sent successfully")
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: SMTP test diagnostic returns success=False; no auth-relevant outcome (route is admin-gated by SETTINGS_UPDATE upstream)
         logger.error("Failed to send test email: %s", e)
         return TestSMTPResponse(success=False, message="Failed to send test email")
 
@@ -698,7 +722,7 @@ async def save_smtp_config(
         await db.commit()
         logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
         return {"message": "SMTP settings saved successfully"}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); SMTP settings unchanged on error
         await db.rollback()
         logger.error("Failed to save SMTP settings: %s", e)
         raise HTTPException(
@@ -743,7 +767,7 @@ async def enable_advanced_auth(
         await db.commit()
         logger.info(f"Advanced authentication enabled by admin user: {user.username}")
         return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); advanced-auth setting unchanged on error
         await db.rollback()
         logger.error("Failed to enable advanced authentication: %s", e)
         raise HTTPException(
@@ -777,7 +801,7 @@ async def disable_advanced_auth(
         await db.commit()
         logger.info(f"Advanced authentication disabled by admin user: {user.username}")
         return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); advanced-auth setting unchanged on error
         await db.rollback()
         logger.error("Failed to disable advanced authentication: %s", e)
         raise HTTPException(
@@ -826,7 +850,7 @@ async def _send_reset_email_or_delete_token(
     try:
         send_email(smtp_settings, to_email, subject, text_body, html_body)
         _logger.info("Password reset email sent (%s) to %s", log_label, to_email)
-    except Exception as exc:
+    except Exception as exc:  # SEC-AUTH-EXC: email-send failure → defensive token cleanup so a stuck token doesn't block re-request; no access granted, just frees future workflow
         _logger.error(
             "Password reset email failed (%s) to %s — deleting token to unblock re-request: %s",
             log_label,
@@ -842,7 +866,7 @@ async def _send_reset_email_or_delete_token(
                     )
                 )
                 await db.commit()
-        except Exception as db_exc:
+        except Exception as db_exc:  # SEC-AUTH-EXC: nested cleanup failure logged only; no access decision made in this branch (already handling a prior failure)
             _logger.error("Failed to delete reset token after send failure: %s", db_exc)
 
 
@@ -967,7 +991,7 @@ async def forgot_password(
                 "forgot_password",
             )
             _logger.info("Password reset email queued for %s", user.email)
-        except Exception as e:
+        except Exception as e:  # SEC-AUTH-EXC: forgot-password response is intentionally generic regardless of outcome (user-enumeration defence); email failure does not grant access
             _logger.error("Failed to send password reset email: %s", e)
             # Don't reveal error to caller for security
 
@@ -1114,7 +1138,7 @@ async def reset_user_password(
 
         _logger.info("Admin password reset link queued for user '%s' by admin '%s'", user.username, admin_user.username)
         return ResetPasswordResponse(message=f"Password reset link sent to {user.email}")
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); reset token state unchanged on error
         await db.rollback()
         _logger.error("Failed to send admin password reset for user '%s': %s", user.username, e)
         raise HTTPException(
@@ -1354,7 +1378,7 @@ async def search_ldap_directory(
 
     try:
         results = search_ldap_users(config, query, limit=25)
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: raise 503 (fail-closed); route gated upstream by USERS_CREATE permission so detail leak is admin-only
         _logger.exception("LDAP directory search failed")
         # Admin-only endpoint — surface the underlying reason so the operator
         # can fix it (auth_middleware already restricted access to USERS_CREATE).
@@ -1430,7 +1454,7 @@ async def provision_ldap_user(
     # "username doesn't exist in the directory" in the UI.
     try:
         ldap_user = lookup_ldap_user(config, username)
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: raise 503 (fail-closed); LDAP provision never succeeds on lookup failure
         _logger.exception("LDAP lookup failed during provision")
         raise HTTPException(
             status_code=status.HTTP_503_SERVICE_UNAVAILABLE,

+ 117 - 19
backend/app/api/routes/library.py

@@ -1074,20 +1074,73 @@ async def delete_folder(
 
 # ============ External Folder Endpoints ============
 
-# Blocked system directories that cannot be mounted
-_BLOCKED_PREFIXES = (
-    "/proc",
-    "/sys",
-    "/dev",
-    "/run",
-    "/boot",
-    "/sbin",
-    "/bin",
-    "/usr/sbin",
-    "/usr/bin",
-    "/lib",
-    "/etc",
-)
+# GHSA-r2qv follow-up (audit finding I1): external-folder mount path uses an
+# allowlist of operator-opted-in roots rather than the original denylist of
+# system directories. The denylist shape was fail-open-on-growth — anything
+# not enumerated (``/data`` containing other users' archives, ``/root``,
+# arbitrary NFS/SMB mounts, the Bambuddy ``LOG_DIR``) could be mounted by any
+# user with ``LIBRARY_UPLOAD``. The allowlist defaults to empty and is
+# extended via the ``BAMBUDDY_EXTERNAL_ROOTS`` env var (colon-separated
+# absolute paths). The route is additionally gated on ``SETTINGS_UPDATE``
+# (admin scope) rather than ``LIBRARY_UPLOAD`` because mounting host paths
+# is an operator-level capability that crosses user boundaries.
+
+
+# Bambuddy-owned data directories. Hardcode-rejected even if the operator
+# tries to add them to ``BAMBUDDY_EXTERNAL_ROOTS`` — mounting these would
+# allow reading other users' archives, log files, or the static assets path.
+def _bambuddy_reserved_roots() -> tuple[Path, ...]:
+    """Resolved Bambuddy-owned directories that may NEVER be mounted as an
+    external folder regardless of the operator's allowlist.
+
+    Resolved at call time because tests patch ``settings.base_dir`` /
+    ``settings.log_dir`` to a temp dir; resolving lazily picks up the
+    patched values rather than module-import-time values.
+    """
+    from backend.app.core.config import settings as app_settings
+
+    reserved = [app_settings.base_dir, app_settings.log_dir, app_settings.static_dir, app_settings.archive_dir]
+    return tuple(Path(p).resolve() for p in reserved if p is not None)
+
+
+def _allowed_external_roots() -> tuple[Path, ...]:
+    """Parse ``BAMBUDDY_EXTERNAL_ROOTS`` into resolved allowed roots.
+
+    Empty env var (the default) means external folders are disabled.
+    Operators opt in explicitly: ``BAMBUDDY_EXTERNAL_ROOTS=/mnt/library:/srv/3d``
+    Returns a tuple of resolved ``Path`` objects; entries that don't
+    resolve to absolute paths are silently dropped (operator error, not
+    a security boundary). Resolved lazily so tests can monkeypatch.
+    """
+    raw = os.environ.get("BAMBUDDY_EXTERNAL_ROOTS", "")
+    roots: list[Path] = []
+    for entry in raw.split(":"):
+        entry = entry.strip()
+        if not entry:
+            continue
+        try:
+            resolved = Path(entry).resolve()
+        except (OSError, RuntimeError):  # noqa: BLE001 — operator config error, not a security boundary
+            continue
+        if resolved.is_absolute():
+            roots.append(resolved)
+    return tuple(roots)
+
+
+def _path_within(child: Path, parent: Path) -> bool:
+    """Return True if ``child`` is ``parent`` or any descendant.
+
+    Uses ``Path.relative_to`` semantics (raises ``ValueError`` on miss)
+    instead of string ``startswith``, which would falsely match
+    ``/data-other`` against ``/data``. ``Path.is_relative_to`` is the
+    sanctioned form on Python 3.9+; both are available here.
+    """
+    try:
+        child.relative_to(parent)
+    except ValueError:
+        return False
+    return True
+
 
 # Supported file extensions for external folder scanning
 _SCANNABLE_EXTENSIONS = {
@@ -1108,15 +1161,53 @@ _SCANNABLE_EXTENSIONS = {
 
 
 def _validate_external_path(path_str: str) -> Path:
-    """Validate an external path is safe to mount."""
+    """Validate an external path is safe to mount.
+
+    Allowlist semantics:
+    1. Path must be absolute and resolve cleanly (symlink-escape rejected
+       implicitly by the resolved-startswith check below).
+    2. Path must fall under one of the roots enumerated in
+       ``BAMBUDDY_EXTERNAL_ROOTS``; empty allowlist (the default)
+       means external folders are not available on this deployment.
+    3. Path must NOT fall under any Bambuddy-owned directory (``base_dir``,
+       ``log_dir``, ``static_dir``, ``archive_dir``) — the reserved set
+       takes precedence over the allowlist, so an operator who accidentally
+       sets ``BAMBUDDY_EXTERNAL_ROOTS=/`` does not expose ``/data``.
+    4. Existence + directory-type + readability gates remain.
+    """
     path = Path(path_str).resolve()
 
     if not path.is_absolute():
         raise HTTPException(status_code=400, detail="Path must be absolute")
 
-    for prefix in _BLOCKED_PREFIXES:
-        if str(path).startswith(prefix):
-            raise HTTPException(status_code=400, detail=f"Cannot mount system directory: {prefix}")
+    allowed_roots = _allowed_external_roots()
+    if not allowed_roots:
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                "External folders are not enabled on this deployment. Ask the "
+                "operator to set BAMBUDDY_EXTERNAL_ROOTS=<colon-separated paths>."
+            ),
+        )
+
+    # Reserved (Bambuddy-owned) paths are rejected before the allowlist check
+    # so an over-broad allowlist (e.g. operator set "/" for testing) cannot
+    # expose Bambuddy's own data dir or log dir.
+    for reserved in _bambuddy_reserved_roots():
+        if _path_within(path, reserved):
+            raise HTTPException(
+                status_code=400,
+                detail=f"Cannot mount Bambuddy-managed directory: {reserved}",
+            )
+
+    if not any(_path_within(path, root) for root in allowed_roots):
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                f"Path '{path}' is not within an allowed external root. "
+                f"Allowed roots: {', '.join(str(r) for r in allowed_roots)}"
+            ),
+        )
 
     if not path.exists():
         raise HTTPException(status_code=400, detail=f"Path does not exist: {path}")
@@ -1135,7 +1226,14 @@ def _validate_external_path(path_str: str) -> Path:
 async def create_external_folder(
     data: ExternalFolderCreate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+    # GHSA-r2qv follow-up (I1): elevated from LIBRARY_UPLOAD to SETTINGS_UPDATE.
+    # Registering a host filesystem path as a Bambuddy library folder is an
+    # operator-level capability that crosses user boundaries (one user's
+    # registered external folder is visible to every other user via
+    # /api/v1/library/folders). LIBRARY_UPLOAD was always the wrong scope —
+    # SETTINGS_UPDATE is the admin-class gate that already protects every
+    # other host-affecting setting (SMTP, LDAP, cloud, smart plugs).
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.SETTINGS_UPDATE)),
 ):
     """Create an external folder that points to a host directory."""
     resolved = _validate_external_path(data.external_path)

+ 4 - 1
backend/app/api/routes/metrics.py

@@ -1,6 +1,7 @@
 """Prometheus metrics endpoint for external monitoring."""
 
 import platform
+import secrets
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Response
 from sqlalchemy import func, select
@@ -75,7 +76,9 @@ async def get_metrics(
         if not authorization.startswith("Bearer "):
             raise HTTPException(status_code=401, detail="Bearer token required")
         provided_token = authorization[7:]  # Remove "Bearer " prefix
-        if provided_token != token:
+        # Constant-time comparison closes the byte-by-byte timing oracle that
+        # plain ``!=`` opens on a LAN-attached attacker (audit finding I2).
+        if not secrets.compare_digest(provided_token.encode("utf-8"), token.encode("utf-8")):
             raise HTTPException(status_code=401, detail="Invalid token")
 
     lines: list[str] = []

+ 11 - 3
backend/app/api/routes/settings.py

@@ -361,12 +361,20 @@ async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/check-ffmpeg")
-async def check_ffmpeg():
-    """Check if ffmpeg is installed and available."""
+async def check_ffmpeg(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Check if ffmpeg is installed and available.
+
+    Gated on ``SETTINGS_READ`` (audit finding I4 — the binary path was
+    leaking the host filesystem layout to unauthenticated callers).
+    ``require_permission_if_auth_enabled`` returns ``None`` only when
+    auth is disabled (in which case there's no privacy boundary to
+    enforce); otherwise it raises 401/403 before we get here.
+    """
     from backend.app.services.camera import get_ffmpeg_path
 
     ffmpeg_path = get_ffmpeg_path()
-
     return {
         "installed": ffmpeg_path is not None,
         "path": ffmpeg_path,

+ 78 - 6
backend/app/api/routes/websocket.py

@@ -1,7 +1,27 @@
+"""GHSA-r2qv follow-up — WebSocket auth gate.
+
+Previously ``/api/v1/ws`` accepted *any* network client and immediately
+streamed every ``printer_status`` / ``print_start`` / ``print_complete``
+/ ``archive_*`` / ``inventory_changed`` broadcast back to it. That is
+the GHSA-gc24 shape on a different protocol — anyone who could reach
+the HTTP port could subscribe to every printer event in the system.
+
+This endpoint now validates a short-lived token (minted by
+``POST /api/v1/auth/ws-token`` behind ``Permission.WEBSOCKET_CONNECT``)
+*before* ``websocket.accept()``. When auth is disabled, no token is
+required (the legacy SPA-friendly path). The token is reused across
+reconnects within its 60-minute window so a brief network blip does
+not require a round-trip to the auth router.
+"""
+
+from __future__ import annotations
+
 import logging
 
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 
+from backend.app.core.auth import is_auth_enabled, verify_websocket_token
+from backend.app.core.database import async_session
 from backend.app.core.websocket import ws_manager
 from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
@@ -9,16 +29,68 @@ from backend.app.services.printer_manager import printer_manager, printer_state_
 logger = logging.getLogger(__name__)
 router = APIRouter()
 
+# 4401 mirrors the WebSocket "unauthorised" application close code
+# convention used by Sec-WebSocket-Protocol authors (private-use range
+# is 4000-4999 per RFC 6455). The SPA distinguishes 4401 from network
+# drops and refetches a token instead of retrying with the old one.
+_WS_CLOSE_UNAUTHORIZED = 4401
+
 
 @router.websocket("/ws")
-async def websocket_endpoint(websocket: WebSocket):
-    """WebSocket endpoint for real-time updates."""
-    logger.info("WebSocket client connecting...")
+async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)) -> None:
+    """WebSocket endpoint for real-time updates.
+
+    Connection auth (GHSA-r2qv follow-up):
+
+    - Auth disabled  → connect without a token, identical to the prior
+      behaviour (single-user / local-network deployments).
+    - Auth enabled   → ``?token=<value>`` query param must hold an
+      unexpired token minted via ``POST /api/v1/auth/ws-token``.
+      Missing / invalid / expired token → ``close(code=4401)`` *before*
+      ``accept()`` so no ``ws_manager.broadcast`` ever reaches the
+      caller (broadcasts walk ``active_connections`` blindly — letting
+      an unauthenticated socket into that list is a fan-out leak).
+
+    The auth check is fail-closed at every error path: a DB exception
+    while reading the ``auth_enabled`` setting closes the connection
+    rather than admitting the caller.
+    """
+    # Authenticate before accept() so an unauth caller never lands in
+    # ws_manager.active_connections (where broadcasts blindly fan out).
+    try:
+        async with async_session() as db:
+            auth_required = await is_auth_enabled(db)
+    except Exception:  # SEC-AUTH-EXC: DB failure on auth probe → fail-closed (refuse connect), matches is_auth_enabled itself which returns True on error
+        logger.error("WebSocket auth probe failed; refusing connection", exc_info=True)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
+
+    principal: str | None = None
+    if auth_required:
+        if not token:
+            logger.info("WebSocket connect refused: no token (auth enabled)")
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
+        principal = await verify_websocket_token(token)
+        if principal is None:
+            logger.info("WebSocket connect refused: invalid or expired token")
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
+
+    # Token verified (or auth disabled); now safe to admit the connection.
+    logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
     await ws_manager.connect(websocket)
+    # Stash on connection state for any future per-message permission
+    # logic; today the message handlers are read-only and only respond
+    # to the requesting socket, so the stash is informational. The
+    # explicit attribute (rather than a side dict) means a future
+    # ``broadcast_to_principal()`` helper can filter on it without
+    # touching every call site.
+    websocket.state.bambuddy_principal = principal
     logger.info("WebSocket client connected")
 
     try:
-        # Send initial status of all printers
+        # Send initial status of all printers.
         statuses = printer_manager.get_all_statuses()
         for printer_id, state in statuses.items():
             await websocket.send_json(
@@ -39,7 +111,7 @@ async def websocket_endpoint(websocket: WebSocket):
             )
         logger.info("Sent initial status for %s printers", len(statuses))
 
-        # Keep connection alive and handle incoming messages
+        # Keep connection alive and handle incoming messages.
         while True:
             data = await websocket.receive_json()
 

+ 150 - 11
backend/app/core/auth.py

@@ -504,6 +504,72 @@ async def create_camera_stream_token() -> str:
     return token
 
 
+WEBSOCKET_TOKEN_EXPIRE_MINUTES = 60
+
+
+async def create_websocket_token(username: str | None) -> str:
+    """Create a short-lived token for ``/api/v1/ws`` connections.
+
+    Mirrors the camera-stream-token pattern: opaque random string stored
+    in ``auth_ephemeral_tokens`` with type ``"websocket"`` so the WS
+    endpoint can verify it *before* calling ``websocket.accept()``.
+
+    Records the issuing principal in the ``username`` field — for JWT
+    callers this is the actual username, for API-keyed callers this is
+    the empty string (handled in the route layer; we accept None at this
+    interface so the auth-disabled path doesn't have to fabricate one).
+
+    The 60-minute expiry matches camera tokens: long enough to survive
+    page reloads / brief disconnects, short enough that a leaked token
+    is not a credential.
+    """
+    now = datetime.now(timezone.utc)
+    expires_at = now + timedelta(minutes=WEBSOCKET_TOKEN_EXPIRE_MINUTES)
+    token = secrets.token_urlsafe(24)
+    async with async_session() as db:
+        # Prune expired tokens opportunistically (same shape as camera).
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == "websocket",
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type="websocket",
+                username=username or "",
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
+    return token
+
+
+async def verify_websocket_token(token: str) -> str | None:
+    """Verify a WebSocket connect token.
+
+    Returns the recorded ``username`` (possibly ``""`` for API-key
+    callers, never ``None`` on success) when the token is valid, or
+    ``None`` when it is missing / expired / unknown. The token is
+    NOT consumed — a single page reload should not need a new round
+    trip to mint a replacement.
+    """
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == "websocket",
+                AuthEphemeralToken.expires_at > now,
+            )
+        )
+        row = result.scalar_one_or_none()
+        if row is None:
+            return None
+        return row.username or ""
+
+
 async def verify_camera_stream_token(token: str) -> bool:
     """Verify a camera stream token is valid (reusable — does not consume it).
 
@@ -748,7 +814,7 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
                 api_key.last_used = datetime.now(timezone.utc)
                 await db.commit()
                 return api_key
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: validation failure returns None; every caller treats None as "invalid key" → 401 (fail-closed)
         logger.warning("API key validation error: %s", e)
     return None
 
@@ -939,19 +1005,92 @@ def require_role(required_role: str):
 
 
 def require_admin_if_auth_enabled():
-    """Dependency factory that requires admin role if auth is enabled."""
+    """Dependency factory that requires admin role if auth is enabled.
+
+    GHSA-r2qv follow-up (audit pattern P3): explicitly fail-closed for API
+    keys. The previous implementation chained on ``require_auth_if_enabled``
+    which returns ``None`` for *both* "auth disabled" *and* "valid API
+    key" — the inner ``admin_checker`` then treated ``None`` as auth-
+    disabled and admitted the caller. If any route had ever adopted this
+    dep, any API key with no scope flags set would have satisfied an
+    admin requirement.
+
+    Today no route uses this dep, but rather than leave the footgun
+    armed, the dep is rewritten to distinguish the two cases by
+    consulting ``is_auth_enabled`` directly and rejecting API-keyed
+    requests with 403. "Admin" requires a user-identity role, which API
+    keys do not carry.
+    """
 
     async def admin_checker(
-        current_user: Annotated[User | None, Depends(require_auth_if_enabled)] = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
-        if current_user is None:
-            return None  # Auth not enabled, allow access
-        if current_user.role != "admin":
-            raise HTTPException(
-                status_code=status.HTTP_403_FORBIDDEN,
-                detail="Requires admin role",
-            )
-        return current_user
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None  # Auth disabled — no role to check.
+
+            # Reject API-keyed requests up front: admin is a user-role
+            # concept, not a key-scope concept. The right path for
+            # admin-equivalent API-key access is a specific Permission
+            # (e.g. SETTINGS_UPDATE) gated by the allowlist, not the
+            # admin role.
+            if x_api_key or (credentials and credentials.credentials.startswith("bb_")):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail="Admin operations require a user role; API keys cannot be admins",
+                )
+
+            # Standard JWT path: validate and require admin role.
+            if credentials is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Authentication required",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            try:
+                payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                iat: int | float | None = payload.get("iat")
+            except JWTError:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            if not _is_token_fresh(iat, user):
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            if user.role != "admin":
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail="Requires admin role",
+                )
+            return user
 
     return admin_checker
 

+ 37 - 6
backend/tests/integration/test_external_folders_api.py

@@ -8,6 +8,21 @@ import pytest
 from httpx import AsyncClient
 
 
+@pytest.fixture(autouse=True)
+def _enable_external_roots(monkeypatch, tmp_path):
+    """Permit pytest's ``tmp_path`` tree as a valid external root.
+
+    After the GHSA-r2qv I1 fix, external folders are opt-in via the
+    ``BAMBUDDY_EXTERNAL_ROOTS`` env var (empty by default → feature
+    disabled). The test suite's external dirs live under pytest's
+    per-session ``tmp_path`` root, which is a subtree of the OS tmp
+    dir, so allowlisting the parent of ``tmp_path`` lets every test
+    folder fixture pass the new validator. Autouse so individual tests
+    don't have to know the env var exists.
+    """
+    monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(tmp_path.parent))
+
+
 class TestExternalFolderCreation:
     """Tests for POST /library/folders/external."""
 
@@ -53,11 +68,18 @@ class TestExternalFolderCreation:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
-        """Verify 400 for non-existent path."""
+    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session, tmp_path):
+        """Verify 400 for non-existent path within an allowed root.
+
+        After GHSA-r2qv I1 the allowlist check runs before the existence
+        check, so the test path must be inside ``BAMBUDDY_EXTERNAL_ROOTS``
+        (= ``tmp_path.parent`` per ``_enable_external_roots``) to actually
+        exercise the existence branch rather than the allowlist branch.
+        """
+        bad_path = tmp_path / "nonexistent" / "subdir"
         data = {
             "name": "Bad Path",
-            "external_path": "/nonexistent/path/that/does/not/exist",
+            "external_path": str(bad_path),
         }
         response = await async_client.post("/api/v1/library/folders/external", json=data)
         assert response.status_code == 400
@@ -65,15 +87,24 @@ class TestExternalFolderCreation:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
-        """Verify system directories are blocked."""
+    async def test_create_external_folder_outside_allowlist_blocked(self, async_client: AsyncClient, db_session):
+        """Paths outside ``BAMBUDDY_EXTERNAL_ROOTS`` are rejected (GHSA-r2qv I1).
+
+        Prior behaviour was a denylist (``/proc``, ``/sys``, ``/dev``, etc);
+        anything not enumerated passed, including ``/data`` containing
+        other users' archives. The allowlist replacement defaults to the
+        empty set; this test confirms that a path outside the (tmp-path)
+        allowlist set up by ``_enable_external_roots`` is rejected.
+        ``/proc`` is the canonical example of a system directory that
+        any operator allowlist would never legitimately include.
+        """
         data = {
             "name": "System",
             "external_path": "/proc",
         }
         response = await async_client.post("/api/v1/library/folders/external", json=data)
         assert response.status_code == 400
-        assert "system directory" in response.json()["detail"].lower()
+        assert "not within an allowed external root" in response.json()["detail"].lower()
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 115 - 0
backend/tests/unit/test_no_fail_open_in_auth.py

@@ -0,0 +1,115 @@
+"""GHSA-6mf4-q26m-47pv backstop: no fail-open ``except Exception`` in auth code.
+
+The advisory's root cause was a single ``except Exception:`` block in the
+auth probe path that returned ``False`` (treated as "auth disabled, allow
+everything") when the DB raised an error during the check. CVSS 9.8.
+Other Python ecosystems would call this CWE-636 ("Not Failing
+Securely").
+
+The fundamental problem is that ``except Exception:`` is too broad to
+audit at review time — the reviewer cannot tell, just from the except
+clause, whether the handler re-raises, denies, or silently returns a
+permissive value. So every such block in auth-sensitive code must be
+explicitly tagged with the reviewer's audit conclusion using the
+``# SEC-AUTH-EXC: <reason>`` marker. Untagged blocks fail this
+test.
+
+The tag forces three things every time an ``except Exception:`` lands
+in scope:
+1. A reviewer has read the handler body and confirmed fail-closed semantics.
+2. The reasoning is captured at the exact line so future readers can verify.
+3. ``grep SEC-AUTH-EXC`` enumerates every audited exception path for spot-checks.
+
+Scope (where this rule applies, mirrors SECURITY.md rule 2):
+- backend/app/core/auth.py
+- backend/app/core/permissions.py
+- backend/app/api/routes/auth.py
+
+To add a new ``except Exception:`` block in scope, append a comment
+``# SEC-AUTH-EXC: <short reason>`` on the same line as the
+``except`` keyword. The reason should describe what makes the handler
+safe (e.g. "rollback + raise 500", "returns None which caller treats
+as invalid → 401", "logged only, no access decision made here").
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+# Files in scope for the lint. Adding a file here widens the safety net;
+# removing one weakens it. Either decision belongs in a PR description.
+IN_SCOPE: tuple[Path, ...] = (
+    REPO_ROOT / "backend" / "app" / "core" / "auth.py",
+    REPO_ROOT / "backend" / "app" / "core" / "permissions.py",
+    REPO_ROOT / "backend" / "app" / "api" / "routes" / "auth.py",
+)
+
+TAG_MARKER = "# SEC-AUTH-EXC:"
+
+
+def _is_broad_except(handler: ast.ExceptHandler) -> bool:
+    """Return True if ``handler`` catches Exception or bare ``except:``.
+
+    Excludes narrower catches like ``except (OperationalError, ProgrammingError):``
+    or ``except JWTError:`` which are explicit about what they handle and
+    not the GHSA-6mf4 shape.
+    """
+    if handler.type is None:
+        return True  # bare `except:`
+    # Single Name catching Exception
+    if isinstance(handler.type, ast.Name) and handler.type.id == "Exception":
+        return True
+    # Tuple catching Exception alongside other types — e.g. `except (Exception, OSError):`
+    if isinstance(handler.type, ast.Tuple):
+        return any(isinstance(elt, ast.Name) and elt.id == "Exception" for elt in handler.type.elts)
+    return False
+
+
+@pytest.mark.unit
+def test_no_fail_open_in_auth_modules() -> None:
+    """SEC-AUTH-2 (SECURITY.md): every broad except in auth modules must carry SEC-AUTH-EXC tag.
+
+    Walks the AST of each in-scope module, finds every ``except Exception:``
+    (or bare ``except:``) block, and asserts the source line containing
+    the ``except`` keyword has a ``# SEC-AUTH-EXC: <reason>`` tag.
+
+    The tag is the reviewer's signed-off audit conclusion. Without it,
+    the broad except is indistinguishable from the GHSA-6mf4 shape.
+    """
+    findings: list[str] = []
+    for source_path in IN_SCOPE:
+        assert source_path.is_file(), f"Expected in-scope file at {source_path}"
+        source = source_path.read_text(encoding="utf-8")
+        source_lines = source.splitlines()
+        tree = ast.parse(source)
+        relative = source_path.relative_to(REPO_ROOT)
+
+        for node in ast.walk(tree):
+            if not isinstance(node, ast.ExceptHandler):
+                continue
+            if not _is_broad_except(node):
+                continue
+
+            # The ``except`` keyword is on node.lineno. Comment must appear
+            # on that line (1-indexed in ast, 0-indexed in our list).
+            line_text = source_lines[node.lineno - 1]
+            if TAG_MARKER not in line_text:
+                # Show enough context for the operator to find the block.
+                handler_preview = line_text.strip()
+                findings.append(
+                    f"  {relative}:{node.lineno}  {handler_preview}\n"
+                    f"      → add `{TAG_MARKER} <reason>` describing why this is fail-closed"
+                )
+
+    assert not findings, (
+        "Untagged ``except Exception:`` (or bare ``except:``) blocks found in auth modules. "
+        "Each one is indistinguishable at review time from the GHSA-6mf4-q26m-47pv shape (CVSS 9.8). "
+        "Either narrow the catch to the specific exception type you handle, or tag the line with "
+        "`# SEC-AUTH-EXC: <reason>` documenting what makes the handler fail-closed. "
+        "See SECURITY.md rule 2 'Fail-closed in auth code'.\n\n" + "\n".join(findings)
+    )

+ 158 - 0
backend/tests/unit/test_no_hardcoded_secrets.py

@@ -0,0 +1,158 @@
+"""GHSA-gc24-px2r-5qmf backstop: no hardcoded fallback secrets in source.
+
+The first half of GHSA-gc24-px2r-5qmf (CVSS 9.8) was a literal
+``bambuddy-secret-key-change-in-production`` string used as the JWT
+signing key when ``JWT_SECRET_KEY`` was unset. Production Docker images
+shipped with that exact string — meaning anyone who pulled the image
+could forge admin tokens for any Bambuddy instance running unmodified.
+
+This test walks every source file in ``backend/app/`` at parse time and
+flags string literals that look like credential fallbacks. It is
+deliberately stricter than the actual exploit: any
+``*-change-in-production`` / ``change-me`` / ``your-secret-here``
+shaped string is a code smell at a security boundary, regardless of
+whether the call site happens to enforce env-var presence today. The
+goal is to keep that string class out of the codebase entirely so
+future code paths cannot re-introduce the same vulnerability shape.
+
+If you need one of these strings as a test input (e.g. asserting that
+a forged token signed with the old leaked secret is *rejected*), use
+the ``ALLOWED_TEST_INPUT_PATTERNS`` allowlist below — never the
+production source.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+# Substring patterns that should never appear in production source as
+# string literals. Case-insensitive substring match.
+FORBIDDEN_PATTERNS: tuple[str, ...] = (
+    "change-in-production",
+    "change-me-in-production",
+    "your-secret-here",
+    "your-secret-key",
+    "default-secret-key",
+    "insecure-default",
+    "placeholder-secret",
+    "replace-this-secret",
+    # The exact leaked value from GHSA-gc24 — keep as a regression marker
+    # so any reintroduction is caught loudly with the CVE number attached.
+    "bambuddy-secret-key-change-in-production",
+)
+
+# Production-source files where these patterns are TOLERATED because they
+# document the historical leak (CHANGELOG / migration notes / security
+# advisory references) rather than being used as a credential fallback.
+# Add an entry with a `# reason: ...` comment, never silently.
+ALLOWED_PRODUCTION_FILES: frozenset[Path] = frozenset()
+
+
+def _python_files_under(root: Path) -> list[Path]:
+    """Yield every .py file under ``root`` excluding caches and virtualenvs."""
+    return [
+        p
+        for p in root.rglob("*.py")
+        if "__pycache__" not in p.parts and ".venv" not in p.parts and "venv" not in p.parts
+    ]
+
+
+def _string_literals_in(file_path: Path) -> list[tuple[int, str]]:
+    """Return (lineno, value) for every string literal in ``file_path``.
+
+    Uses ``ast`` to avoid false positives from comments / docstrings;
+    docstrings are ``ast.Constant`` too but we explicitly include them
+    because a docstring is not a safe place to put a credential either.
+    Returns an empty list on syntax-error files rather than crashing —
+    a parse failure means the file has a separate bug and we don't want
+    this test to mask it.
+    """
+    try:
+        tree = ast.parse(file_path.read_text(encoding="utf-8"))
+    except (SyntaxError, UnicodeDecodeError):
+        return []
+    return [
+        (node.lineno, node.value)
+        for node in ast.walk(tree)
+        if isinstance(node, ast.Constant) and isinstance(node.value, str)
+    ]
+
+
+@pytest.mark.unit
+def test_no_hardcoded_secrets_in_production_source() -> None:
+    """SEC-AUTH-3 (SECURITY.md): no credential-shaped fallback strings in backend/app/.
+
+    Walks every Python source file under ``backend/app/``. Flags string
+    literals matching any pattern in ``FORBIDDEN_PATTERNS``. Allowlisted
+    files (e.g. tests asserting we reject the leaked GHSA-gc24 token)
+    are exempt via ``ALLOWED_PRODUCTION_FILES``.
+
+    Failure here means a code change has reintroduced the GHSA-gc24
+    failure mode: a string literal that production code could fall
+    back to as a credential, defeating the env-var-or-fail design of
+    ``_get_jwt_secret()``.
+    """
+    repo_root = Path(__file__).resolve().parents[3]
+    production_root = repo_root / "backend" / "app"
+    assert production_root.is_dir(), f"Expected backend/app/ at {production_root}"
+
+    findings: list[str] = []
+    for src in _python_files_under(production_root):
+        relative = src.relative_to(repo_root)
+        if relative in ALLOWED_PRODUCTION_FILES:
+            continue
+        for lineno, literal in _string_literals_in(src):
+            literal_lower = literal.lower()
+            for pattern in FORBIDDEN_PATTERNS:
+                if pattern in literal_lower:
+                    findings.append(f"  {relative}:{lineno} contains forbidden pattern '{pattern}': {literal!r}")
+                    break
+
+    assert not findings, (
+        "Hardcoded credential-shaped strings found in production source — "
+        "this is the GHSA-gc24-px2r-5qmf shape (CVSS 9.8 hardcoded JWT secret). "
+        "See SECURITY.md rule 3 'No hardcoded fallback secrets'.\n\n" + "\n".join(findings)
+    )
+
+
+@pytest.mark.unit
+def test_jwt_secret_loader_has_no_hardcoded_fallback() -> None:
+    """SEC-AUTH-3 (SECURITY.md): _get_jwt_secret never returns a literal string.
+
+    The post-GHSA-gc24 design of ``_get_jwt_secret`` reads from env, then
+    file, then generates a random value via ``secrets.token_urlsafe``.
+    No code path returns a string literal. This test asserts that
+    structural property by walking the function's AST and confirming
+    every ``return`` statement returns either a Name (variable) or a
+    Call (function result), never an ast.Constant string literal.
+
+    If this test fails, ``_get_jwt_secret`` has been modified to return
+    a hardcoded value somewhere — likely as a "convenience default" that
+    will end up in a shipped Docker image, which is exactly how the
+    original GHSA-gc24 advisory happened.
+    """
+    repo_root = Path(__file__).resolve().parents[3]
+    auth_module = repo_root / "backend" / "app" / "core" / "auth.py"
+    tree = ast.parse(auth_module.read_text(encoding="utf-8"))
+
+    loader: ast.FunctionDef | None = None
+    for node in ast.walk(tree):
+        if isinstance(node, ast.FunctionDef) and node.name == "_get_jwt_secret":
+            loader = node
+            break
+
+    assert loader is not None, "_get_jwt_secret() not found in backend/app/core/auth.py — has it been renamed?"
+
+    literal_returns: list[tuple[int, str]] = []
+    for node in ast.walk(loader):
+        if isinstance(node, ast.Return) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
+            literal_returns.append((node.lineno, node.value.value))
+
+    assert not literal_returns, (
+        "_get_jwt_secret() has a string-literal return — this is the GHSA-gc24 vulnerability shape. "
+        "Use os.environ + file storage + secrets.token_urlsafe; never return a hardcoded string.\n"
+        + "\n".join(f"  auth.py:{ln}: returns {val!r}" for ln, val in literal_returns)
+    )

+ 226 - 0
backend/tests/unit/test_route_auth_coverage.py

@@ -0,0 +1,226 @@
+"""GHSA-gc24 + GHSA-r2qv backstop: every route has an explicit auth dep.
+
+The "second half" of GHSA-gc24-px2r-5qmf was that 77 endpoints out of 117
+responded to anonymous requests with full payloads. The fix at the time
+was retroactive — auth deps were added route by route. This test makes
+the requirement structural: every FastAPI route at the app-level (HTTP
+and WebSocket) is walked, and each one either has an auth dependency or
+is in the ``PUBLIC_ROUTES`` allowlist with a justification comment.
+
+Adding an unauthenticated route now requires touching the allowlist.
+The diff makes the intent visible in code review and the entry-with-
+reason format documents *why* this is safe (login itself, status
+heartbeat, etc.). Drift catches the same failure mode that surfaced
+the original advisory.
+
+The audit also covers WebSocket routes — the proactive sweep that
+surfaced finding C1 (`/api/v1/ws` was fully unauthenticated) showed
+that an APIRoute-only walk has a blind spot for the very route shape
+that produced the most severe disclosure.
+"""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+from fastapi.routing import APIRoute, APIWebSocketRoute
+
+from backend.app.main import app
+
+# Substring patterns identifying auth-bearing callable qualnames in the
+# resolved Depends() tree. Inner functions returned by factories carry
+# the outer factory's name in their qualname (e.g.
+# ``require_permission.<locals>.permission_checker``), so a substring
+# check is enough; we don't have to enumerate the inner names.
+_AUTH_QUALNAME_PATTERNS: tuple[str, ...] = (
+    "require_",  # require_permission, require_permission_if_auth_enabled, require_role, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
+    "cloud_caller",  # cloud.py route-level dep
+    "_cloud_api_key_gate",  # cloud.py router-level dep
+    "resolve_api_key_cloud_owner",  # used by slicer routes that need the API key's owner
+    "get_current_user",  # JWT identity resolution
+    "get_current_active_user",  # JWT identity resolution
+    "get_api_key",  # webhook routes use this directly
+    "verify_websocket_token",  # WebSocket route inline check (GHSA-r2qv I-WS)
+)
+
+# Routes that are intentionally accessible without an auth dependency.
+# Each entry MUST be (method, path) tuple — the path is matched against
+# ``route.path`` literally. To add an entry: include a justification on
+# the line above explaining why anonymous access is safe.
+_PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
+    {
+        # ---- HTTP API: auth bootstrap (pre-credential or token-self-validated) ----
+        # First-run setup — runs before any user exists. Idempotent once setup_completed is true.
+        ("POST", "/api/v1/auth/setup"),
+        # Login itself — credentials in the request body ARE the auth.
+        ("POST", "/api/v1/auth/login"),
+        # Logout — clears server-side JTI revocation; degraded behaviour on bad token is acceptable.
+        ("POST", "/api/v1/auth/logout"),
+        # Status heartbeat — used by the login UI to decide whether to show login form.
+        ("GET", "/api/v1/auth/status"),
+        # Advanced-auth status (whether 2FA / OIDC / LDAP are configured) — read by login form.
+        ("GET", "/api/v1/auth/advanced-auth/status"),
+        # LDAP status (whether LDAP login is configured) — read by login form.
+        ("GET", "/api/v1/auth/ldap/status"),
+        # OIDC discovery — login form needs the list of providers + their icons before user picks one.
+        ("GET", "/api/v1/auth/oidc/providers"),
+        ("GET", "/api/v1/auth/oidc/providers/{provider_id}/icon"),
+        # OIDC authorize / callback / exchange — protocol-level handshakes that validate state nonces inline.
+        ("GET", "/api/v1/auth/oidc/authorize/{provider_id}"),
+        ("GET", "/api/v1/auth/oidc/callback"),
+        ("POST", "/api/v1/auth/oidc/exchange"),
+        # 2FA send + verify — issued after password check; pre-auth token in cookie is the auth.
+        ("POST", "/api/v1/auth/2fa/email/send"),
+        ("POST", "/api/v1/auth/2fa/verify"),
+        # Forgot-password (anonymous request) + confirm (signed token in the URL).
+        ("POST", "/api/v1/auth/forgot-password"),
+        ("POST", "/api/v1/auth/forgot-password/confirm"),
+        # ---- HTTP API: signed-URL routes (token in path is the auth) ----
+        # Signed download URLs — token in path validated by the handler.
+        ("GET", "/api/v1/archives/{archive_id}/dl/{token}/{filename}"),
+        ("GET", "/api/v1/archives/{archive_id}/source-dl/{token}/{filename}"),
+        ("GET", "/api/v1/library/files/{file_id}/dl/{token}/{filename}"),
+        # Obico cached frame — one-time nonce embedded in <img> tags.
+        ("GET", "/api/v1/obico/cached-frame/{nonce}"),
+        # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.
+        ("GET", "/api/v1/makerworld/thumbnail"),
+        # ---- HTTP API: operational + UI-bootstrap (no sensitive data) ----
+        # Operational liveness probe — minimal payload, used by container orchestrators.
+        ("GET", "/health"),
+        # Prometheus metrics — gated by its own bearer token check (constant-time post-I2).
+        ("GET", "/api/v1/metrics"),
+        # UI bootstrap — defaults for sidebar order and ui-preferences are public defaults that ship with the app.
+        ("GET", "/api/v1/settings/default-sidebar-order"),
+        ("GET", "/api/v1/settings/ui-preferences"),
+        # Slicer printer-models — static catalog, no user data.
+        ("GET", "/api/v1/slicer/printer-models"),
+        # Current Bambuddy version — public info (already visible in HTTP response headers + Docker tags).
+        ("GET", "/api/v1/updates/version"),
+        # Webhook routes — auth lives inside the handler via get_api_key() + check_permission(), not as a Depends.
+        # Once they all migrate to standard auth deps these entries come out; for now exempting the file.
+        ("GET", "/api/v1/webhook/printer/{printer_id}/status"),
+        ("GET", "/api/v1/webhook/queue"),
+        ("POST", "/api/v1/webhook/printer/{printer_id}/cancel"),
+        ("POST", "/api/v1/webhook/printer/{printer_id}/start"),
+        ("POST", "/api/v1/webhook/printer/{printer_id}/stop"),
+        ("POST", "/api/v1/webhook/queue/add"),
+        # ---- Static / SPA routes (not user data) ----
+        ("GET", "/"),
+        ("GET", "/manifest.json"),
+        ("GET", "/sw-register.js"),
+        ("GET", "/sw.js"),
+        ("GET", "/gcode-viewer/"),
+        ("GET", "/gcode-viewer/{file_path:path}"),
+        # SPA catch-all — serves index.html for client-side routing. No backend data path.
+        ("GET", "/{full_path:path}"),
+        # ---- WebSocket routes ----
+        # /ws performs an inline ``verify_websocket_token`` check before
+        # ``accept()`` (GHSA-r2qv WS fix). The qualname matches one of the
+        # auth-bearing patterns above, so this entry is informational — the
+        # walker recognises the inline check as auth.
+    }
+)
+
+
+def _walk_dependant_qualnames(dependant) -> list[str]:
+    """Flatten the dependant tree to a list of callable qualnames."""
+    names: list[str] = []
+    if dependant is None:
+        return names
+    if dependant.call:
+        names.append(getattr(dependant.call, "__qualname__", "?"))
+    for sub in dependant.dependencies:
+        names.extend(_walk_dependant_qualnames(sub))
+    return names
+
+
+def _has_auth_dep(dependant) -> bool:
+    """True if any callable in the dependant tree matches an auth pattern."""
+    return any(any(p in qn for p in _AUTH_QUALNAME_PATTERNS) for qn in _walk_dependant_qualnames(dependant))
+
+
+def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
+    """True if the websocket endpoint reads its source uses ``verify_websocket_token``.
+
+    WebSocket routes don't pass auth via the standard Depends machinery
+    (the WebSocket handshake doesn't carry headers), so the auth check
+    lives inline in the endpoint body. We confirm by inspecting the
+    endpoint function's source text — looking for an actual call to
+    ``verify_websocket_token``. A docstring-only mention would NOT
+    satisfy this check (we look for a call-shaped pattern, not a
+    substring).
+    """
+    import inspect
+
+    try:
+        source = inspect.getsource(route.endpoint)
+    except (OSError, TypeError):
+        return False
+    return bool(re.search(r"\bverify_websocket_token\s*\(", source))
+
+
+@pytest.mark.unit
+def test_routes_have_explicit_auth_deps() -> None:
+    """SEC-AUTH-1 (SECURITY.md): every API route has an auth dep or is in the public allowlist.
+
+    Walks both ``APIRoute`` (HTTP) and ``APIWebSocketRoute`` (WS)
+    objects on the live FastAPI app. For each, asserts that at least
+    one of the resolved Depends in the dependant tree matches an auth-
+    bearing qualname, OR that the (method, path) pair is in the
+    explicit public-route allowlist, OR (for WebSocket routes) that
+    the endpoint performs an inline ``verify_websocket_token`` check.
+
+    Failure means a new route is reachable anonymously without being
+    documented as such — the GHSA-gc24 / GHSA-r2qv shape.
+    """
+    failures: list[str] = []
+
+    for route in app.routes:
+        if isinstance(route, APIRoute):
+            method = sorted(route.methods)[0] if route.methods else "GET"
+            if _has_auth_dep(route.dependant):
+                continue
+            if (method, route.path) in _PUBLIC_ROUTES:
+                continue
+            failures.append(f"  {method:7} {route.path}  → no auth dep, not in _PUBLIC_ROUTES allowlist")
+        elif isinstance(route, APIWebSocketRoute):
+            if _has_auth_dep(route.dependant):
+                continue
+            if _ws_endpoint_does_inline_token_check(route):
+                continue
+            if ("WS", route.path) in _PUBLIC_ROUTES:
+                continue
+            failures.append(f"  WS      {route.path}  → no auth dep, no inline token check, not in _PUBLIC_ROUTES")
+
+    assert not failures, (
+        "Routes without an auth dependency that aren't in the public allowlist. "
+        "Either add a ``Depends(require_*)`` to the route OR add the (method, path) "
+        "to ``_PUBLIC_ROUTES`` with a comment justifying why anonymous access is safe. "
+        "See SECURITY.md rule 1 'Allowlist over denylist' (route allowlist sub-section).\n\n" + "\n".join(failures)
+    )
+
+
+@pytest.mark.unit
+def test_public_routes_allowlist_matches_real_routes() -> None:
+    """Drift-detection: every (method, path) in ``_PUBLIC_ROUTES`` must exist on the app.
+
+    If a route is renamed or removed, the entry for it in the allowlist
+    becomes dead — a residual rubber-stamp that does nothing but leaves
+    the impression that the route still has anonymous access. This test
+    flags those.
+    """
+    real_routes: set[tuple[str, str]] = set()
+    for route in app.routes:
+        if isinstance(route, APIRoute):
+            method = sorted(route.methods)[0] if route.methods else "GET"
+            real_routes.add((method, route.path))
+        elif isinstance(route, APIWebSocketRoute):
+            real_routes.add(("WS", route.path))
+
+    stale = sorted(_PUBLIC_ROUTES - real_routes)
+    assert not stale, (
+        "_PUBLIC_ROUTES contains entries that no longer match any real route. "
+        "Remove these stale entries (the route was renamed, removed, or its method changed).\n\n"
+        + "\n".join(f"  {m:7} {p}" for m, p in stale)
+    )

+ 18 - 0
docker-compose.yml

@@ -69,6 +69,14 @@ services:
       # Enable the system trust store with the USE_SYSTEM_TRUST_STORE env var to
       # have Bambuddy trust the certificate.
       # - /path/to/certs:/usr/local/share/ca-certificates
+      #
+      # External library folders. Mount the host paths the operator wants
+      # users to be able to register as external folders. The in-container
+      # paths chosen here MUST appear in BAMBUDDY_EXTERNAL_ROOTS below.
+      # Read-only (:ro) is recommended unless you want users uploading
+      # files back to the host share.
+      #- /mnt/nas/3d-prints:/external/nas:ro
+      #- /srv/library:/external/projects:ro
     environment:
       - TZ=${TZ:-Europe/Berlin}
       # User/group the container drops to after the entrypoint normalises
@@ -101,6 +109,16 @@ services:
       # to manage the key out-of-band (e.g. via a secret manager).
       #- MFA_ENCRYPTION_KEY=
       #
+      # External library folders (GHSA-r2qv follow-up). Empty default
+      # disables the "Add external folder" feature; set to one or more
+      # colon-separated absolute paths INSIDE THE CONTAINER to opt in.
+      # The paths must also be bind-mounted from the host — uncomment
+      # the matching volume snippet below.
+      # Example for a single NAS mount:
+      #- BAMBUDDY_EXTERNAL_ROOTS=/external/nas
+      # Example for two roots:
+      #- BAMBUDDY_EXTERNAL_ROOTS=/external/nas:/external/projects
+      #
       # Enable System Trust Store for certificate validation (e.g. for local Home Assistant)
       # You also need to mount your certificates to the container (see volumes section above).
       # - USE_SYSTEM_TRUST_STORE=true

+ 8 - 2
frontend/src/App.tsx

@@ -153,10 +153,16 @@ function SetupRoute({ children }: { children: React.ReactNode }) {
 function App() {
   return (
     <ErrorBoundary>
-    <ThemeProvider>
       <ToastProvider>
         <QueryClientProvider client={queryClient}>
           <AuthProvider>
+            {/* ThemeProvider sits inside AuthProvider so its initial
+                ``api.getSettings()`` fetch can wait for AuthContext to
+                resolve — otherwise it fires unconditionally on every
+                login page load and returns 401. ErrorBoundary uses
+                inline styles, so a missing theme on a crash screen is
+                not a regression. */}
+            <ThemeProvider>
             <ColorCatalogProvider>
             <SliceJobTrackerProvider>
             <StreamTokenSync />
@@ -213,10 +219,10 @@ function App() {
             </BrowserRouter>
             </SliceJobTrackerProvider>
             </ColorCatalogProvider>
+            </ThemeProvider>
           </AuthProvider>
         </QueryClientProvider>
       </ToastProvider>
-    </ThemeProvider>
     </ErrorBoundary>
   );
 }

+ 8 - 5
frontend/src/__tests__/contexts/AuthContext.test.tsx

@@ -24,11 +24,14 @@ function createWrapper() {
     return (
       <QueryClientProvider client={queryClient}>
         <BrowserRouter>
-          <ThemeProvider>
-            <ToastProvider>
-              <AuthProvider>{children}</AuthProvider>
-            </ToastProvider>
-          </ThemeProvider>
+          {/* ThemeProvider sits inside AuthProvider (matches App.tsx
+              after the GHSA-r2qv login-page-quiet fix) so its
+              useAuth() call resolves. */}
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>{children}</ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
         </BrowserRouter>
       </QueryClientProvider>
     );

+ 6 - 6
frontend/src/__tests__/contexts/ColorCatalogContext.test.tsx

@@ -27,13 +27,13 @@ function createWrapper() {
     return (
       <QueryClientProvider client={queryClient}>
         <BrowserRouter>
-          <ThemeProvider>
-            <ToastProvider>
-              <AuthProvider>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>
                 <ColorCatalogProvider>{children}</ColorCatalogProvider>
-              </AuthProvider>
-            </ToastProvider>
-          </ThemeProvider>
+              </ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
         </BrowserRouter>
       </QueryClientProvider>
     );

+ 17 - 1
frontend/src/__tests__/contexts/ThemeContext.test.tsx

@@ -5,6 +5,8 @@
 import { describe, it, expect, beforeEach } from 'vitest';
 import { renderHook, act } from '@testing-library/react';
 import { ThemeProvider, useTheme } from '../../contexts/ThemeContext';
+import { AuthProvider } from '../../contexts/AuthContext';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import type { ReactNode } from 'react';
 
 // Helper to create a controllable matchMedia mock for individual tests
@@ -41,7 +43,21 @@ function mockMatchMedia(prefersDark: boolean) {
 }
 
 function wrapper({ children }: { children: ReactNode }) {
-  return <ThemeProvider>{children}</ThemeProvider>;
+  // ThemeProvider now calls useAuth() internally to gate its initial
+  // api.getSettings() fetch (GHSA-r2qv login-page-quiet fix), so we
+  // need AuthProvider in the tree. QueryClientProvider is required by
+  // any AuthProvider descendant that calls useQuery (none here, but
+  // AuthProvider itself uses ``api.request`` via getAuthStatus).
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false, gcTime: 0 } },
+  });
+  return (
+    <QueryClientProvider client={queryClient}>
+      <AuthProvider>
+        <ThemeProvider>{children}</ThemeProvider>
+      </AuthProvider>
+    </QueryClientProvider>
+  );
 }
 
 describe('ThemeContext', () => {

+ 64 - 22
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -107,8 +107,27 @@ function createWrapper(queryClient: QueryClient) {
   };
 }
 
-function getLatestWs(): MockWebSocket | undefined {
-  return wsInstances[wsInstances.length - 1];
+/**
+ * After GHSA-r2qv, useWebSocket awaits a ws-token fetch before constructing
+ * the WebSocket. The MockWebSocket isn't pushed into ``wsInstances`` until
+ * that promise resolves. ``waitFor`` from testing-library uses real-time
+ * polling and so wedges under ``vi.useFakeTimers()``; flushing microtasks
+ * manually works under both real and fake timers because Promise resolution
+ * runs on the microtask queue, not on the mocked clock.
+ *
+ * Two iterations suffice for ``await fetch(...)`` → ``await resp.json()``;
+ * a small headroom lets future awaits land here without changing every
+ * call site.
+ */
+async function waitForWs(): Promise<MockWebSocket> {
+  for (let i = 0; i < 10 && wsInstances.length === 0; i++) {
+    await Promise.resolve();
+  }
+  const ws = wsInstances[wsInstances.length - 1];
+  if (!ws) {
+    throw new Error('WebSocket was not constructed after microtask flush');
+  }
+  return ws;
 }
 
 describe('useWebSocket hook', () => {
@@ -122,10 +141,28 @@ describe('useWebSocket hook', () => {
     // Save original and install mock
     originalWebSocket = globalThis.WebSocket;
     globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
+
+    // After GHSA-r2qv, useWebSocket fetches a ws-token via api.getWebSocketToken
+    // before opening the socket. ``api.request`` reads ``response.headers``
+    // and ``response.status``; the stub must expose those (a missing
+    // ``headers`` field throws inside request() and the silent catch in
+    // useWebSocket then proceeds with an undefined token, so the assertion
+    // "URL contains ?token=" fails without making the cause obvious).
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => ({
+        ok: true,
+        status: 200,
+        statusText: 'OK',
+        headers: { get: () => null },
+        json: async () => ({ token: 'test-ws-token' }),
+      })),
+    );
   });
 
   afterEach(() => {
     vi.restoreAllMocks();
+    vi.unstubAllGlobals();
     // Restore original WebSocket
     globalThis.WebSocket = originalWebSocket;
   });
@@ -190,9 +227,11 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs();
+      const ws = await waitForWs();
       expect(ws).toBeDefined();
-      expect(ws?.url).toContain('/api/v1/ws');
+      expect(ws.url).toContain('/api/v1/ws');
+      // GHSA-r2qv: the ws-token mint result is appended as ?token=...
+      expect(ws.url).toContain('token=test-ws-token');
     });
 
     it('reports connected state when WebSocket opens', async () => {
@@ -206,9 +245,9 @@ describe('useWebSocket hook', () => {
       expect(result.current.isConnected).toBe(false);
 
       // Simulate connection opening
-      const ws = getLatestWs();
+      const ws = await waitForWs();
       act(() => {
-        ws?.open();
+        ws.open();
       });
 
       await waitFor(() => {
@@ -285,7 +324,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {
@@ -327,7 +366,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {
@@ -368,7 +407,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {
@@ -405,7 +444,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
       act(() => {
         ws.open();
       });
@@ -435,7 +474,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {
@@ -460,7 +499,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {
@@ -490,7 +529,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {
@@ -519,7 +558,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {
@@ -542,7 +581,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Don't open connection - still in CONNECTING state
 
@@ -564,7 +603,11 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const firstWs = getLatestWs()!;
+      // GHSA-r2qv: connect() awaits a ws-token fetch before constructing
+      // the WebSocket. Flush microtasks under fake timers so the await
+      // resolves and MockWebSocket is pushed into wsInstances.
+      await vi.advanceTimersByTimeAsync(0);
+      const firstWs = wsInstances[wsInstances.length - 1]!;
 
       // Open connection
       act(() => {
@@ -578,14 +621,13 @@ describe('useWebSocket hook', () => {
         firstWs.close();
       });
 
-      // Wait for reconnect timeout (3 seconds)
-      act(() => {
-        vi.advanceTimersByTime(3000);
-      });
+      // Wait for reconnect timeout (3 seconds) + microtask flush for the
+      // async connect() that the reconnect schedules.
+      await vi.advanceTimersByTimeAsync(3000);
 
       // Should have created new WebSocket
       expect(wsInstances.length).toBe(instanceCountBefore + 1);
-      expect(getLatestWs()).not.toBe(firstWs);
+      expect(wsInstances[wsInstances.length - 1]).not.toBe(firstWs);
 
       vi.useRealTimers();
     });
@@ -597,7 +639,7 @@ describe('useWebSocket hook', () => {
         wrapper: createWrapper(queryClient),
       });
 
-      const ws = getLatestWs()!;
+      const ws = await waitForWs();
 
       // Open connection
       act(() => {

+ 4 - 4
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -44,15 +44,15 @@ function renderCameraPage(printerId: number, search = '') {
     <QueryClientProvider client={queryClient}>
       <I18nextProvider i18n={i18n}>
         <MemoryRouter initialEntries={[`/cameras/${printerId}${search}`]}>
-          <ThemeProvider>
-            <AuthProvider>
+          <AuthProvider>
+            <ThemeProvider>
               <ToastProvider>
                 <Routes>
                   <Route path="/cameras/:printerId" element={<CameraPage />} />
                 </Routes>
               </ToastProvider>
-            </AuthProvider>
-          </ThemeProvider>
+            </ThemeProvider>
+          </AuthProvider>
         </MemoryRouter>
       </I18nextProvider>
     </QueryClientProvider>

+ 6 - 6
frontend/src/__tests__/pages/GroupEditPage.test.tsx

@@ -237,18 +237,18 @@ describe('GroupEditPage', () => {
 
       const wrapper = (
         <QueryClientProvider client={queryClient}>
-          <ThemeProvider>
-            <ToastProvider>
-              <AuthProvider>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>
                 <MemoryRouter initialEntries={['/groups/2/edit']}>
                   <Routes>
                     <Route path="/groups/:id/edit" element={<GroupEditPage />} />
                     <Route path="/settings" element={<div>Settings</div>} />
                   </Routes>
                 </MemoryRouter>
-              </AuthProvider>
-            </ToastProvider>
-          </ThemeProvider>
+              </ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
         </QueryClientProvider>
       );
       rtlRender(wrapper);

+ 10 - 7
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -11,6 +11,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
 
 const mockPrinter = {
   id: 1,
@@ -60,13 +61,15 @@ function renderOverlayPage(printerId: number, queryParams = '') {
   return rtlRender(
     <QueryClientProvider client={queryClient}>
       <MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
-        <ThemeProvider>
-          <ToastProvider>
-            <Routes>
-              <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
-            </Routes>
-          </ToastProvider>
-        </ThemeProvider>
+        <AuthProvider>
+          <ThemeProvider>
+            <ToastProvider>
+              <Routes>
+                <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
+              </Routes>
+            </ToastProvider>
+          </ThemeProvider>
+        </AuthProvider>
       </MemoryRouter>
     </QueryClientProvider>
   );

+ 46 - 0
frontend/src/__tests__/setup.ts

@@ -87,6 +87,52 @@ vi.stubGlobal('WebSocket', MockWebSocket);
 // Mock scrollTo
 window.scrollTo = vi.fn();
 
+// Silence jsdom's "Not implemented: navigation (except hash changes)"
+// warning when production code does ``window.location.href = '/setup'``
+// (AuthContext setup-redirect) or other full-page nav assignments.
+//
+// jsdom defines ``href`` as a non-configurable accessor on
+// ``Location.prototype``, so it cannot be redefined on the instance via
+// ``Object.defineProperty``. We wrap the real jsdom Location in a Proxy
+// that turns ``href = "..."`` writes into silent no-ops; everything
+// else (reads of ``pathname`` / ``search`` / ``hash``, writes to
+// ``hash``, ``assign()`` / ``replace()`` calls, ``history.replaceState``
+// updating ``search``) passes through unchanged. The ``get`` trap is
+// deliberately permissive: returning a substitute value for a non-
+// configurable target property violates Proxy invariants and the spread
+// operator (``{ ...window.location }``) walks every key — tests that
+// use the spread to copy the location object must keep working.
+{
+  const realLocation = window.location;
+  const locationProxy = new Proxy(realLocation, {
+    set(target, prop, value) {
+      if (prop === 'href') {
+        // Silently swallow "navigation not implemented". Tests asserting
+        // on the redirect should replace ``window.location`` themselves
+        // (several existing tests do exactly this).
+        return true;
+      }
+      Reflect.set(target, prop, value);
+      return true;
+    },
+    get(target, prop, receiver) {
+      // Return the exact value present on the target. Returning a
+      // bound/wrapped version of a non-configurable function (``assign``
+      // is one) violates Proxy invariants (the spread operator at one
+      // call site triggers this). Production code that does
+      // ``window.location.assign(url)`` calls the function with the
+      // proxy as ``this``, which jsdom still accepts because its
+      // Location methods unwrap their receiver internally.
+      return Reflect.get(target, prop, receiver);
+    },
+  });
+  Object.defineProperty(window, 'location', {
+    configurable: true,
+    writable: true,
+    value: locationProxy,
+  });
+}
+
 // Mock localStorage
 const localStorageMock = {
   getItem: vi.fn(),

+ 9 - 4
frontend/src/__tests__/utils.tsx

@@ -36,11 +36,16 @@ function AllProviders({ children }: AllProvidersProps) {
   return (
     <QueryClientProvider client={queryClient}>
       <BrowserRouter>
-        <ThemeProvider>
-          <AuthProvider>
+        {/* ThemeProvider is now mounted inside AuthProvider in App.tsx so
+            its initial ``api.getSettings()`` sync can gate on auth state.
+            Tests follow the same nesting; otherwise ThemeProvider's
+            ``useAuth()`` throws "AuthContext must be used inside
+            AuthProvider". */}
+        <AuthProvider>
+          <ThemeProvider>
             <ToastProvider>{children}</ToastProvider>
-          </AuthProvider>
-        </ThemeProvider>
+          </ThemeProvider>
+        </AuthProvider>
       </BrowserRouter>
     </QueryClientProvider>
   );

+ 6 - 0
frontend/src/api/client.ts

@@ -5088,6 +5088,12 @@ export const api = {
   getCameraStreamToken: () =>
     request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),
 
+  // WebSocket auth (GHSA-r2qv follow-up) — mint a short-lived token for
+  // the /ws connection. Browsers can't attach Authorization headers to a
+  // WebSocket handshake, so the token rides in the ?token= query param.
+  getWebSocketToken: () =>
+    request<{ token: string }>('/auth/ws-token', { method: 'POST' }),
+
   // Long-lived camera-stream tokens (#1108)
   createLongLivedCameraToken: (payload: { name: string; expires_in_days: number }) =>
     request<LongLivedCameraToken>('/auth/tokens', {

+ 19 - 2
frontend/src/contexts/ThemeContext.tsx

@@ -1,5 +1,6 @@
 import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
 import { api } from '../api/client';
+import { useAuth } from './AuthContext';
 
 type ThemeMode = 'light' | 'dark' | 'system';
 type ThemeStyle = 'classic' | 'glow' | 'vibrant';
@@ -32,6 +33,14 @@ interface ThemeContextType {
 const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
 
 export function ThemeProvider({ children }: { children: ReactNode }) {
+  // Auth-aware: read state from AuthContext so the initial getSettings()
+  // sync waits until we know whether (a) auth is enabled and (b) there
+  // is a logged-in user. Without this the fetch fires on the login page
+  // and returns 401 — harmless (the catch swallows it) but noisy in the
+  // network panel and wasteful. ThemeProvider is mounted inside
+  // AuthProvider in App.tsx specifically so this hook is callable.
+  const { authEnabled, user, loading: authLoading } = useAuth();
+
   // Mode
   const [mode, setModeState] = useState<ThemeMode>(() => {
     const stored = localStorage.getItem('theme-mode') as ThemeMode | null;
@@ -78,8 +87,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
     return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
   });
 
-  // Sync from API on mount
+  // Sync from API once auth state is known. Same gate shape as
+  // useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
+  // settle, then only fetch when we can actually expect a 200 (auth
+  // disabled, or auth enabled with a logged-in user).
   useEffect(() => {
+    if (authLoading) return;
+    if (authEnabled && user === null) return;
     api.getSettings().then((settings) => {
       // Dark settings
       if (settings.dark_style) {
@@ -108,7 +122,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
         localStorage.setItem('light-accent', settings.light_accent);
       }
     }).catch(() => {});
-  }, []);
+    // Re-fetch when auth state transitions (e.g. login completes); the
+    // gate above short-circuits subsequent calls once we already have a
+    // valid sync.
+  }, [authLoading, authEnabled, user]);
 
   // Apply theme classes based on current mode
   useEffect(() => {

+ 9 - 2
frontend/src/hooks/useCameraStreamToken.ts

@@ -41,17 +41,24 @@ export function rewriteMediaSrcWithToken(root: ParentNode, token: string): numbe
  * Components that need token-protected URLs can import withStreamToken directly.
  */
 export function useStreamTokenSync() {
-  const { authEnabled, user } = useAuth();
+  const { authEnabled, user, loading: authLoading } = useAuth();
   const queryClient = useQueryClient();
   const refreshingRef = useRef(false);
 
   // Key the token by user id so a login/logout invalidates the cache
   // automatically — otherwise a failed anonymous fetch on the login page
   // would be cached and never retried after sign-in.
+  //
+  // Race-aware gate (same shape as ColorCatalogProvider): wait for
+  // ``checkAuthStatus`` to finish before deciding whether to fetch.
+  // The previous form ``authEnabled ? !!user : true`` evaluated to
+  // ``true`` on first render because ``authEnabled`` defaults to false,
+  // firing a 401 POST on the login page before AuthContext had a chance
+  // to settle on ``authEnabled=true, user=null``.
   const { data } = useQuery({
     queryKey: ['camera-stream-token', user?.id ?? null],
     queryFn: () => api.getCameraStreamToken(),
-    enabled: authEnabled ? !!user : true,
+    enabled: !authLoading && (!authEnabled || user !== null),
     staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
     refetchInterval: 50 * 60 * 1000,
   });

+ 28 - 3
frontend/src/hooks/useWebSocket.ts

@@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
 import { useCallback, useEffect, useRef, useState } from 'react';
 import { useToast } from '../contexts/ToastContext';
 import { useTranslation } from 'react-i18next';
+import { api } from '../api/client';
 
 interface WebSocketMessage {
   type: string;
@@ -64,13 +65,34 @@ export function useWebSocket() {
     processNext();
   }, []);
 
-  const connect = useCallback(() => {
+  const connect = useCallback(async () => {
     if (wsRef.current?.readyState === WebSocket.OPEN) {
       return;
     }
 
+    // GHSA-r2qv follow-up: when auth is enabled, /ws now requires a token
+    // minted by POST /api/v1/auth/ws-token. We use the shared ``api.request``
+    // helper (via ``api.getWebSocketToken``) so the JWT Authorization header
+    // is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
+    // miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
+    // Auth-disabled deployments accept connections without a token, so we
+    // treat a missing/failed token mint as non-fatal here and let the
+    // WebSocket close with code 4401 if the server actually rejects us.
+    let token: string | undefined;
+    try {
+      const resp = await api.getWebSocketToken();
+      token = resp.token;
+    } catch {
+      // Token mint failed — most likely auth is disabled (no JWT to attach,
+      // 401 response) or the user isn't authenticated yet. Fall through and
+      // try the WebSocket anyway. Auth-disabled deployments succeed;
+      // auth-enabled deployments close with 4401 and the reconnect loop
+      // kicks in once the user logs in.
+    }
+
     const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
-    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
+    const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
+    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
 
     const ws = new WebSocket(wsUrl);
 
@@ -356,7 +378,10 @@ export function useWebSocket() {
   }, [handleMessage]);
 
   useEffect(() => {
-    connect();
+    // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
+    // Fire-and-forget at mount; the inner reconnect loop also calls
+    // connect() in the ws.onclose handler.
+    void connect();
 
     return () => {
       if (reconnectTimeoutRef.current) {

+ 32 - 8
frontend/src/pages/StreamOverlayPage.tsx

@@ -144,11 +144,33 @@ export function StreamOverlayPage() {
   useEffect(() => {
     if (!id) return;
 
-    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
-    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
-    const ws = new WebSocket(wsUrl);
+    let ws: WebSocket | null = null;
+    let cancelled = false;
+
+    // GHSA-r2qv follow-up: mint a ws-token before connecting. Uses
+    // api.getWebSocketToken so the JWT Authorization header rides along
+    // (raw fetch+credentials:'include' would miss it — Bambuddy uses
+    // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
+    // succeed even without a token.
+    (async () => {
+      let token: string | undefined;
+      try {
+        const resp = await api.getWebSocketToken();
+        token = resp.token;
+      } catch {
+        // Token mint failed — auth disabled, no JWT yet, or transient
+        // network error. Fall through; auth-disabled deployments still
+        // succeed, auth-enabled ones close with 4401 and the page's
+        // polling fallback continues to refresh the status.
+      }
+      if (cancelled) return;
 
-    ws.onmessage = (event) => {
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+      const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
+      const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
+      ws = new WebSocket(wsUrl);
+
+      ws.onmessage = (event) => {
       try {
         const data = JSON.parse(event.data);
         if (data.type === 'printer_status' && data.printer_id === id) {
@@ -159,12 +181,14 @@ export function StreamOverlayPage() {
       }
     };
 
-    ws.onerror = () => {
-      // WebSocket error - polling will continue as fallback
-    };
+      ws.onerror = () => {
+        // WebSocket error - polling will continue as fallback
+      };
+    })();
 
     return () => {
-      ws.close();
+      cancelled = true;
+      if (ws) ws.close();
     };
   }, [id, queryClient]);
 

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-BeiuHSbR.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Crwf3Atk.js"></script>
+    <script type="module" crossorigin src="/assets/index-BeiuHSbR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C3FyyVE7.css">
   </head>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio