|
@@ -24,13 +24,120 @@ from backend.app.models.user import User
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
-# SETTINGS_READ is intentionally not denied — the SpoolBuddy kiosk reads settings
|
|
|
|
|
-# via API key (e.g. to sync the UI language).
|
|
|
|
|
|
|
+# GHSA-r2qv-8222-hqg3 (CVSS 9.9) — API key permission enforcement is allowlist-based.
|
|
|
|
|
+#
|
|
|
|
|
+# Until 0.2.4.x, ``_check_apikey_permissions`` only consulted the admin denylist
|
|
|
|
|
+# below. The three documented scope flags on ``APIKey``
|
|
|
|
|
+# (``can_read_status`` / ``can_queue`` / ``can_control_printer`` / ``can_manage_library``)
|
|
|
|
|
+# were enforced only by ``check_permission()`` inside ``routes/webhook.py``;
|
|
|
|
|
+# every other route used ``require_permission_if_auth_enabled`` which fell
|
|
|
|
|
+# through to the denylist-only path, so an API key with all flags unchecked
|
|
|
|
|
+# could still stop prints, edit queue items, and read every endpoint not in
|
|
|
|
|
+# this set. ``require_any_permission_if_auth_enabled`` and
|
|
|
|
|
+# ``require_ownership_permission`` did not call this helper at all, so admin
|
|
|
|
|
+# "any-of" routes and ownership-modify routes were entirely ungated for API keys.
|
|
|
|
|
+#
|
|
|
|
|
+# Fix: ``_check_apikey_permissions`` now requires every requested permission to
|
|
|
|
|
+# be present in ``_APIKEY_SCOPE_BY_PERMISSION`` (allowlist), and gates on the
|
|
|
|
|
+# corresponding scope flag on the API key. Unmapped permissions = 403. This
|
|
|
|
|
+# means a Permission added to ``core/permissions.py`` without a matching entry
|
|
|
|
|
+# in ``_APIKEY_SCOPE_BY_PERMISSION`` is automatically denied for API keys —
|
|
|
|
|
+# the previous denylist shape allowed every new Permission to silently widen
|
|
|
|
|
+# the API-key surface.
|
|
|
|
|
+#
|
|
|
|
|
+# The denylist is retained for documentation / drift-detection only — its
|
|
|
|
|
+# entries also satisfy "not in the allowlist", so they fail closed regardless.
|
|
|
|
|
+#
|
|
|
|
|
+# Mapping rationale (see wiki/features/api-keys.md):
|
|
|
|
|
+# can_read_status → every ``*_READ`` + camera + stats + system + websocket
|
|
|
|
|
+# can_queue → queue write ops + archive reprint
|
|
|
|
|
+# can_control_printer → physical printer + smart-plug control
|
|
|
|
|
+# can_manage_library → library upload/own + MakerWorld import (separate
|
|
|
|
|
+# trust level from queue management, hence its own flag)
|
|
|
|
|
+# admin-only → unmapped (default-deny); covers all create/update/
|
|
|
|
|
+# delete of admin resources, settings writes, user/
|
|
|
|
|
+# group/api-key/backup admin ops, discovery scan,
|
|
|
|
|
+# cloud auth, library ALL-ownership perms, purges
|
|
|
|
|
+_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
|
|
|
|
+ # can_read_status — read-only access to status, history, and configuration
|
|
|
|
|
+ Permission.PRINTERS_READ: "can_read_status",
|
|
|
|
|
+ Permission.ARCHIVES_READ: "can_read_status",
|
|
|
|
|
+ Permission.QUEUE_READ: "can_read_status",
|
|
|
|
|
+ Permission.LIBRARY_READ: "can_read_status",
|
|
|
|
|
+ Permission.PROJECTS_READ: "can_read_status",
|
|
|
|
|
+ Permission.FILAMENTS_READ: "can_read_status",
|
|
|
|
|
+ Permission.INVENTORY_READ: "can_read_status",
|
|
|
|
|
+ Permission.INVENTORY_VIEW_ASSIGNMENTS: "can_read_status",
|
|
|
|
|
+ Permission.INVENTORY_FORECAST_READ: "can_read_status",
|
|
|
|
|
+ Permission.SMART_PLUGS_READ: "can_read_status",
|
|
|
|
|
+ Permission.CAMERA_VIEW: "can_read_status",
|
|
|
|
|
+ Permission.MAINTENANCE_READ: "can_read_status",
|
|
|
|
|
+ Permission.KPROFILES_READ: "can_read_status",
|
|
|
|
|
+ Permission.NOTIFICATIONS_READ: "can_read_status",
|
|
|
|
|
+ Permission.NOTIFICATION_TEMPLATES_READ: "can_read_status",
|
|
|
|
|
+ Permission.EXTERNAL_LINKS_READ: "can_read_status",
|
|
|
|
|
+ Permission.FIRMWARE_READ: "can_read_status",
|
|
|
|
|
+ Permission.AMS_HISTORY_READ: "can_read_status",
|
|
|
|
|
+ Permission.STATS_READ: "can_read_status",
|
|
|
|
|
+ Permission.STATS_FILTER_BY_USER: "can_read_status",
|
|
|
|
|
+ Permission.SYSTEM_READ: "can_read_status",
|
|
|
|
|
+ # SETTINGS_READ stays allowed via read-status so SpoolBuddy kiosks keep
|
|
|
|
|
+ # working (they need the UI-language setting via API key).
|
|
|
|
|
+ Permission.SETTINGS_READ: "can_read_status",
|
|
|
|
|
+ Permission.MAKERWORLD_VIEW: "can_read_status",
|
|
|
|
|
+ Permission.WEBSOCKET_CONNECT: "can_read_status",
|
|
|
|
|
+ # can_queue — queue write ops + reprint (which enqueues an existing archive)
|
|
|
|
|
+ Permission.QUEUE_CREATE: "can_queue",
|
|
|
|
|
+ Permission.QUEUE_UPDATE_OWN: "can_queue",
|
|
|
|
|
+ Permission.QUEUE_UPDATE_ALL: "can_queue",
|
|
|
|
|
+ Permission.QUEUE_DELETE_OWN: "can_queue",
|
|
|
|
|
+ Permission.QUEUE_DELETE_ALL: "can_queue",
|
|
|
|
|
+ Permission.QUEUE_REORDER: "can_queue",
|
|
|
|
|
+ Permission.ARCHIVES_REPRINT_OWN: "can_queue",
|
|
|
|
|
+ Permission.ARCHIVES_REPRINT_ALL: "can_queue",
|
|
|
|
|
+ # can_control_printer — physical-world side effects on hardware
|
|
|
|
|
+ Permission.PRINTERS_CONTROL: "can_control_printer",
|
|
|
|
|
+ Permission.PRINTERS_FILES: "can_control_printer",
|
|
|
|
|
+ Permission.PRINTERS_AMS_RFID: "can_control_printer",
|
|
|
|
|
+ Permission.PRINTERS_CLEAR_PLATE: "can_control_printer",
|
|
|
|
|
+ Permission.SMART_PLUGS_CONTROL: "can_control_printer",
|
|
|
|
|
+ # can_manage_library — file-manager scope (upload/rename/delete OWN library
|
|
|
|
|
+ # entries + MakerWorld import which downloads files into the library).
|
|
|
|
|
+ # Bulk/ALL-ownership library ops (UPDATE_ALL / DELETE_ALL / PURGE) stay
|
|
|
|
|
+ # admin-only because they cross the user boundary.
|
|
|
|
|
+ Permission.LIBRARY_UPLOAD: "can_manage_library",
|
|
|
|
|
+ Permission.LIBRARY_UPDATE_OWN: "can_manage_library",
|
|
|
|
|
+ Permission.LIBRARY_DELETE_OWN: "can_manage_library",
|
|
|
|
|
+ Permission.MAKERWORLD_IMPORT: "can_manage_library",
|
|
|
|
|
+ # can_manage_inventory — inventory write scope. Covers the documented
|
|
|
|
|
+ # spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
|
|
|
|
|
+ # (NFC scan, scale reading, system command/update) which used
|
|
|
|
|
+ # INVENTORY_UPDATE as a stand-in for "kiosk write" under the prior
|
|
|
|
|
+ # denylist model. Read-only inventory (INVENTORY_READ etc.) stays under
|
|
|
|
|
+ # can_read_status.
|
|
|
|
|
+ Permission.INVENTORY_CREATE: "can_manage_inventory",
|
|
|
|
|
+ Permission.INVENTORY_UPDATE: "can_manage_inventory",
|
|
|
|
|
+ Permission.INVENTORY_DELETE: "can_manage_inventory",
|
|
|
|
|
+ Permission.INVENTORY_FORECAST_WRITE: "can_manage_inventory",
|
|
|
|
|
+ # can_access_cloud — narrow opt-in scope, gated by the router-level
|
|
|
|
|
+ # ``_cloud_api_key_gate`` and additionally enforced here so the route-
|
|
|
|
|
+ # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
|
|
|
|
|
+ # when the flag is off (defence-in-depth).
|
|
|
|
|
+ Permission.CLOUD_AUTH: "can_access_cloud",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Retained for documentation, drift-detection, and the prior "administrative
|
|
|
|
|
+# operations" error string. Entries here are also absent from
|
|
|
|
|
+# ``_APIKEY_SCOPE_BY_PERMISSION``, so they fail closed via the allowlist; the
|
|
|
|
|
+# denylist is a redundant explicit "these are admin" marker, not the load-
|
|
|
|
|
+# bearing security check.
|
|
|
_APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
|
|
_APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
|
|
|
{
|
|
{
|
|
|
|
|
+ # Settings administration (cred storage; rewriting these reaches SMTP/LDAP/MQTT).
|
|
|
Permission.SETTINGS_UPDATE,
|
|
Permission.SETTINGS_UPDATE,
|
|
|
Permission.SETTINGS_BACKUP,
|
|
Permission.SETTINGS_BACKUP,
|
|
|
Permission.SETTINGS_RESTORE,
|
|
Permission.SETTINGS_RESTORE,
|
|
|
|
|
+ # User / group / API-key administration.
|
|
|
Permission.USERS_READ,
|
|
Permission.USERS_READ,
|
|
|
Permission.USERS_CREATE,
|
|
Permission.USERS_CREATE,
|
|
|
Permission.USERS_UPDATE,
|
|
Permission.USERS_UPDATE,
|
|
@@ -43,22 +150,112 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
|
|
|
Permission.API_KEYS_UPDATE,
|
|
Permission.API_KEYS_UPDATE,
|
|
|
Permission.API_KEYS_DELETE,
|
|
Permission.API_KEYS_DELETE,
|
|
|
Permission.API_KEYS_READ,
|
|
Permission.API_KEYS_READ,
|
|
|
|
|
+ # GitHub backup admin + firmware OTA.
|
|
|
Permission.GITHUB_BACKUP,
|
|
Permission.GITHUB_BACKUP,
|
|
|
Permission.GITHUB_RESTORE,
|
|
Permission.GITHUB_RESTORE,
|
|
|
Permission.FIRMWARE_UPDATE,
|
|
Permission.FIRMWARE_UPDATE,
|
|
|
|
|
+ # Resource administration (printer/project/filament/maintenance/k-profile/etc CRUD).
|
|
|
|
|
+ # API keys with the operational scopes can read these resources via
|
|
|
|
|
+ # *_READ permissions but cannot mutate the catalog/registry itself.
|
|
|
|
|
+ Permission.PRINTERS_CREATE,
|
|
|
|
|
+ Permission.PRINTERS_UPDATE,
|
|
|
|
|
+ Permission.PRINTERS_DELETE,
|
|
|
|
|
+ Permission.ARCHIVES_CREATE,
|
|
|
|
|
+ Permission.ARCHIVES_UPDATE_OWN,
|
|
|
|
|
+ Permission.ARCHIVES_UPDATE_ALL,
|
|
|
|
|
+ Permission.ARCHIVES_DELETE_OWN,
|
|
|
|
|
+ Permission.ARCHIVES_DELETE_ALL,
|
|
|
|
|
+ Permission.ARCHIVES_PURGE,
|
|
|
|
|
+ Permission.LIBRARY_UPDATE_ALL,
|
|
|
|
|
+ Permission.LIBRARY_DELETE_ALL,
|
|
|
|
|
+ Permission.LIBRARY_PURGE,
|
|
|
|
|
+ Permission.PROJECTS_CREATE,
|
|
|
|
|
+ Permission.PROJECTS_UPDATE,
|
|
|
|
|
+ Permission.PROJECTS_DELETE,
|
|
|
|
|
+ Permission.FILAMENTS_CREATE,
|
|
|
|
|
+ Permission.FILAMENTS_UPDATE,
|
|
|
|
|
+ Permission.FILAMENTS_DELETE,
|
|
|
|
|
+ Permission.MAINTENANCE_CREATE,
|
|
|
|
|
+ Permission.MAINTENANCE_UPDATE,
|
|
|
|
|
+ Permission.MAINTENANCE_DELETE,
|
|
|
|
|
+ Permission.KPROFILES_CREATE,
|
|
|
|
|
+ Permission.KPROFILES_UPDATE,
|
|
|
|
|
+ Permission.KPROFILES_DELETE,
|
|
|
|
|
+ Permission.NOTIFICATIONS_CREATE,
|
|
|
|
|
+ Permission.NOTIFICATIONS_UPDATE,
|
|
|
|
|
+ Permission.NOTIFICATIONS_DELETE,
|
|
|
|
|
+ Permission.NOTIFICATIONS_USER_EMAIL,
|
|
|
|
|
+ Permission.NOTIFICATION_TEMPLATES_UPDATE,
|
|
|
|
|
+ Permission.EXTERNAL_LINKS_CREATE,
|
|
|
|
|
+ Permission.EXTERNAL_LINKS_UPDATE,
|
|
|
|
|
+ Permission.EXTERNAL_LINKS_DELETE,
|
|
|
|
|
+ Permission.SMART_PLUGS_CREATE,
|
|
|
|
|
+ Permission.SMART_PLUGS_UPDATE,
|
|
|
|
|
+ Permission.SMART_PLUGS_DELETE,
|
|
|
|
|
+ # Network scanning — operator only (no API-key scope for this).
|
|
|
|
|
+ Permission.DISCOVERY_SCAN,
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _check_apikey_permissions(perm_strings: list[str]) -> None:
|
|
|
|
|
- """Raise 403 if any required permission is admin-only (not accessible via API key)."""
|
|
|
|
|
- denied = _APIKEY_DENIED_PERMISSIONS.intersection(perm_strings)
|
|
|
|
|
- if denied:
|
|
|
|
|
|
|
+def _resolve_apikey_scope(perm_string: str) -> str | None:
|
|
|
|
|
+ """Return the scope-flag attribute name gating ``perm_string`` for API keys.
|
|
|
|
|
+
|
|
|
|
|
+ None when the permission is unmapped (= admin-only / not API-key-usable).
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ perm = Permission(perm_string)
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return _APIKEY_SCOPE_BY_PERMISSION.get(perm)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, require_any: bool = False) -> None:
|
|
|
|
|
+ """Raise 403 unless ``api_key`` is allowed to use ``perm_strings``.
|
|
|
|
|
+
|
|
|
|
|
+ Allowlist semantics: every requested permission MUST be present in
|
|
|
|
|
+ ``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on
|
|
|
|
|
+ ``api_key``. Unmapped permissions = administrative = 403.
|
|
|
|
|
+
|
|
|
|
|
+ By default ALL requested permissions must pass (mirrors
|
|
|
|
|
+ ``require_permission`` / ``require_permission_if_auth_enabled``).
|
|
|
|
|
+ When ``require_any=True``, only one needs to pass (mirrors
|
|
|
|
|
+ ``require_any_permission_if_auth_enabled``).
|
|
|
|
|
+ """
|
|
|
|
|
+ if not perm_strings:
|
|
|
|
|
+ # Defensive: empty perm list means the dep is auth-only, not perm-gated.
|
|
|
|
|
+ # Routes never call us with [] today, but if they did, returning here
|
|
|
|
|
+ # would silently allow — instead, fail closed.
|
|
|
raise HTTPException(
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
- detail="API keys cannot be used for administrative operations",
|
|
|
|
|
|
|
+ detail="API keys cannot be used for unspecified permissions",
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ last_failure: HTTPException | None = None
|
|
|
|
|
+ for perm_str in perm_strings:
|
|
|
|
|
+ scope_attr = _resolve_apikey_scope(perm_str)
|
|
|
|
|
+ if scope_attr is None:
|
|
|
|
|
+ failure = HTTPException(
|
|
|
|
|
+ status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
+ detail="API keys cannot be used for administrative operations",
|
|
|
|
|
+ )
|
|
|
|
|
+ elif not getattr(api_key, scope_attr, False):
|
|
|
|
|
+ failure = HTTPException(
|
|
|
|
|
+ status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
+ detail=f"API key does not have '{scope_attr}' permission",
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ failure = None
|
|
|
|
|
+
|
|
|
|
|
+ if failure is None and require_any:
|
|
|
|
|
+ return # at least one passed
|
|
|
|
|
+ if failure is not None and not require_any:
|
|
|
|
|
+ raise failure
|
|
|
|
|
+ last_failure = failure
|
|
|
|
|
+
|
|
|
|
|
+ if require_any and last_failure is not None:
|
|
|
|
|
+ raise last_failure
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def require_energy_cost_update():
|
|
def require_energy_cost_update():
|
|
|
"""Dependency for ``POST /settings/electricity-price`` (#1356).
|
|
"""Dependency for ``POST /settings/electricity-price`` (#1356).
|
|
@@ -930,7 +1127,7 @@ def require_permission(*permissions: str | Permission):
|
|
|
if x_api_key:
|
|
if x_api_key:
|
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
- _check_apikey_permissions(perm_strings)
|
|
|
|
|
|
|
+ _check_apikey_permissions(api_key, perm_strings)
|
|
|
return None # API key valid, allow access
|
|
return None # API key valid, allow access
|
|
|
|
|
|
|
|
credentials_exception = HTTPException(
|
|
credentials_exception = HTTPException(
|
|
@@ -947,7 +1144,7 @@ def require_permission(*permissions: str | Permission):
|
|
|
if token.startswith("bb_"):
|
|
if token.startswith("bb_"):
|
|
|
api_key = await _validate_api_key(db, token)
|
|
api_key = await _validate_api_key(db, token)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
- _check_apikey_permissions(perm_strings)
|
|
|
|
|
|
|
+ _check_apikey_permissions(api_key, perm_strings)
|
|
|
return None # API key valid, allow access
|
|
return None # API key valid, allow access
|
|
|
raise HTTPException(
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
@@ -1020,7 +1217,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|
|
if x_api_key:
|
|
if x_api_key:
|
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
- _check_apikey_permissions(perm_strings)
|
|
|
|
|
|
|
+ _check_apikey_permissions(api_key, perm_strings)
|
|
|
return None # API key valid, allow access
|
|
return None # API key valid, allow access
|
|
|
|
|
|
|
|
# Check for Bearer token (could be JWT or API key)
|
|
# Check for Bearer token (could be JWT or API key)
|
|
@@ -1030,7 +1227,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|
|
if token.startswith("bb_"):
|
|
if token.startswith("bb_"):
|
|
|
api_key = await _validate_api_key(db, token)
|
|
api_key = await _validate_api_key(db, token)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
- _check_apikey_permissions(perm_strings)
|
|
|
|
|
|
|
+ _check_apikey_permissions(api_key, perm_strings)
|
|
|
return None # API key valid, allow access
|
|
return None # API key valid, allow access
|
|
|
raise HTTPException(
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
@@ -1120,6 +1317,10 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
|
|
|
if x_api_key:
|
|
if x_api_key:
|
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
|
|
+ # GHSA-r2qv-8222-hqg3: previously returned None unconditionally,
|
|
|
|
|
+ # letting any valid API key satisfy admin "any-of" route
|
|
|
|
|
+ # dependencies. require_any → at-least-one must pass the scope check.
|
|
|
|
|
+ _check_apikey_permissions(api_key, perm_strings, require_any=True)
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
if credentials is not None:
|
|
if credentials is not None:
|
|
@@ -1127,6 +1328,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
|
|
|
if token.startswith("bb_"):
|
|
if token.startswith("bb_"):
|
|
|
api_key = await _validate_api_key(db, token)
|
|
api_key = await _validate_api_key(db, token)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
|
|
+ _check_apikey_permissions(api_key, perm_strings, require_any=True)
|
|
|
return None
|
|
return None
|
|
|
raise HTTPException(
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
@@ -1223,10 +1425,17 @@ def require_ownership_permission(
|
|
|
):
|
|
):
|
|
|
"""Dependency factory for ownership-based permission checks.
|
|
"""Dependency factory for ownership-based permission checks.
|
|
|
|
|
|
|
|
- - User with `all_permission` can modify any item
|
|
|
|
|
- - User with `own_permission` can only modify items where created_by_id == user.id
|
|
|
|
|
- - Ownerless items (created_by_id = null) require `all_permission`
|
|
|
|
|
- - API keys (via X-API-Key header or Bearer bb_xxx) get full access (can_modify_all=True)
|
|
|
|
|
|
|
+ - User with ``all_permission`` can modify any item
|
|
|
|
|
+ - User with ``own_permission`` can only modify items where created_by_id == user.id
|
|
|
|
|
+ - Ownerless items (created_by_id = null) require ``all_permission``
|
|
|
|
|
+ - API keys (via X-API-Key header or Bearer bb_xxx) must satisfy the
|
|
|
|
|
+ ``all_permission``'s API-key scope flag (e.g. ``can_queue`` for
|
|
|
|
|
+ ``QUEUE_UPDATE_ALL``) and then receive ``can_modify_all=True``.
|
|
|
|
|
+ OWN/ALL ownership pairs map to the same scope flag in
|
|
|
|
|
+ ``_APIKEY_SCOPE_BY_PERMISSION`` so checking ``all_permission`` is the
|
|
|
|
|
+ correct gate; API keys have no per-row ownership identity. Pre-
|
|
|
|
|
+ GHSA-r2qv-8222-hqg3 fix this returned ``(None, True)`` for any valid
|
|
|
|
|
+ key with no scope check — see ``core/auth.py`` allowlist commentary.
|
|
|
|
|
|
|
|
Returns:
|
|
Returns:
|
|
|
A dependency function that returns (user, can_modify_all).
|
|
A dependency function that returns (user, can_modify_all).
|
|
@@ -1250,11 +1459,20 @@ def require_ownership_permission(
|
|
|
if not auth_enabled:
|
|
if not auth_enabled:
|
|
|
return None, True # Auth disabled, allow all
|
|
return None, True # Auth disabled, allow all
|
|
|
|
|
|
|
|
- # Check for API key first (X-API-Key header)
|
|
|
|
|
|
|
+ # GHSA-r2qv-8222-hqg3: previously API keys received (None, True)
|
|
|
|
|
+ # unconditionally on ownership-modify routes — a "queue-only" key
|
|
|
|
|
+ # could delete any user's archives, library files, queue items.
|
|
|
|
|
+ # OWN and ALL ownership perms both map to the same scope flag
|
|
|
|
|
+ # (e.g. both QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL → can_queue),
|
|
|
|
|
+ # so checking ``all_perm`` against the api_key's scope is the
|
|
|
|
|
+ # correct gate. API keys don't have per-row ownership identity, so
|
|
|
|
|
+ # on pass we keep can_modify_all=True (preserves prior intent,
|
|
|
|
|
+ # narrows access to keys with the right scope flag).
|
|
|
if x_api_key:
|
|
if x_api_key:
|
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
api_key = await _validate_api_key(db, x_api_key)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
- return None, True # API key valid, allow all
|
|
|
|
|
|
|
+ _check_apikey_permissions(api_key, [all_perm])
|
|
|
|
|
+ return None, True
|
|
|
|
|
|
|
|
# Check for Bearer token (could be JWT or API key)
|
|
# Check for Bearer token (could be JWT or API key)
|
|
|
if credentials is not None:
|
|
if credentials is not None:
|
|
@@ -1263,7 +1481,8 @@ def require_ownership_permission(
|
|
|
if token.startswith("bb_"):
|
|
if token.startswith("bb_"):
|
|
|
api_key = await _validate_api_key(db, token)
|
|
api_key = await _validate_api_key(db, token)
|
|
|
if api_key:
|
|
if api_key:
|
|
|
- return None, True # API key valid, allow all
|
|
|
|
|
|
|
+ _check_apikey_permissions(api_key, [all_perm])
|
|
|
|
|
+ return None, True
|
|
|
raise HTTPException(
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
detail="Invalid API key",
|
|
detail="Invalid API key",
|