mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
fix(security): WebSocket auth gate + audit-driven hardening sweep
This commit is contained in:
parent
9c8df1744d
commit
b7d7c82501
32 changed files with 1420 additions and 234 deletions
19
.env.example
19
.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=
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
80
SECURITY.md
80
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
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
backend/tests/unit/test_no_fail_open_in_auth.py
Normal file
115
backend/tests/unit/test_no_fail_open_in_auth.py
Normal 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)
|
||||
)
|
||||
158
backend/tests/unit/test_no_hardcoded_secrets.py
Normal file
158
backend/tests/unit/test_no_hardcoded_secrets.py
Normal 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)
|
||||
)
|
||||
226
backend/tests/unit/test_route_auth_coverage.py
Normal file
226
backend/tests/unit/test_route_auth_coverage.py
Normal 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)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue