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

This commit is contained in:
maziggy 2026-06-02 10:02:17 +02:00
parent 9c8df1744d
commit b7d7c82501
32 changed files with 1420 additions and 234 deletions

View file

@ -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=

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -81,11 +81,85 @@ The following are **out of scope**:
- Denial of service (DoS) attacks
- Issues requiring physical access to the server
## Acknowledgments
## Bambuddy Security Stance
We thank the following individuals for responsibly disclosing security issues:
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.
*No security issues have been reported yet.*
### 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.
---

View file

@ -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,

View file

@ -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)

View file

@ -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] = []

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)
)

View file

@ -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)
)

View file

@ -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)
)

View file

@ -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

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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', () => {

View file

@ -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(() => {

View file

@ -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>

View file

@ -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);

View file

@ -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>
);

View file

@ -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(),

View file

@ -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>
);

View file

@ -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', {

View file

@ -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(() => {

View file

@ -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,
});

View file

@ -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) {

View file

@ -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;
ws.onmessage = (event) => {
// 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;
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]);

File diff suppressed because one or more lines are too long

View file

@ -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>