test_route_auth_coverage.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """GHSA-gc24 + GHSA-r2qv backstop: every route has an explicit auth dep.
  2. The "second half" of GHSA-gc24-px2r-5qmf was that 77 endpoints out of 117
  3. responded to anonymous requests with full payloads. The fix at the time
  4. was retroactive — auth deps were added route by route. This test makes
  5. the requirement structural: every FastAPI route at the app-level (HTTP
  6. and WebSocket) is walked, and each one either has an auth dependency or
  7. is in the ``PUBLIC_ROUTES`` allowlist with a justification comment.
  8. Adding an unauthenticated route now requires touching the allowlist.
  9. The diff makes the intent visible in code review and the entry-with-
  10. reason format documents *why* this is safe (login itself, status
  11. heartbeat, etc.). Drift catches the same failure mode that surfaced
  12. the original advisory.
  13. The audit also covers WebSocket routes — the proactive sweep that
  14. surfaced finding C1 (`/api/v1/ws` was fully unauthenticated) showed
  15. that an APIRoute-only walk has a blind spot for the very route shape
  16. that produced the most severe disclosure.
  17. """
  18. from __future__ import annotations
  19. import re
  20. import pytest
  21. from fastapi.routing import APIRoute, APIWebSocketRoute
  22. from backend.app.main import app
  23. # Substring patterns identifying auth-bearing callable qualnames in the
  24. # resolved Depends() tree. Inner functions returned by factories carry
  25. # the outer factory's name in their qualname (e.g.
  26. # ``require_permission.<locals>.permission_checker``), so a substring
  27. # check is enough; we don't have to enumerate the inner names.
  28. _AUTH_QUALNAME_PATTERNS: tuple[str, ...] = (
  29. "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
  30. "cloud_caller", # cloud.py route-level dep
  31. "_cloud_api_key_gate", # cloud.py router-level dep
  32. "resolve_api_key_cloud_owner", # used by slicer routes that need the API key's owner
  33. "get_current_user", # JWT identity resolution
  34. "get_current_active_user", # JWT identity resolution
  35. "get_api_key", # webhook routes use this directly
  36. "verify_websocket_token", # WebSocket route inline check (GHSA-r2qv I-WS)
  37. )
  38. # Routes that are intentionally accessible without an auth dependency.
  39. # Each entry MUST be (method, path) tuple — the path is matched against
  40. # ``route.path`` literally. To add an entry: include a justification on
  41. # the line above explaining why anonymous access is safe.
  42. _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
  43. {
  44. # ---- HTTP API: auth bootstrap (pre-credential or token-self-validated) ----
  45. # First-run setup — runs before any user exists. Idempotent once setup_completed is true.
  46. ("POST", "/api/v1/auth/setup"),
  47. # Login itself — credentials in the request body ARE the auth.
  48. ("POST", "/api/v1/auth/login"),
  49. # Logout — clears server-side JTI revocation; degraded behaviour on bad token is acceptable.
  50. ("POST", "/api/v1/auth/logout"),
  51. # Status heartbeat — used by the login UI to decide whether to show login form.
  52. ("GET", "/api/v1/auth/status"),
  53. # Advanced-auth status (whether 2FA / OIDC / LDAP are configured) — read by login form.
  54. ("GET", "/api/v1/auth/advanced-auth/status"),
  55. # LDAP status (whether LDAP login is configured) — read by login form.
  56. ("GET", "/api/v1/auth/ldap/status"),
  57. # OIDC discovery — login form needs the list of providers + their icons before user picks one.
  58. ("GET", "/api/v1/auth/oidc/providers"),
  59. ("GET", "/api/v1/auth/oidc/providers/{provider_id}/icon"),
  60. # OIDC authorize / callback / exchange — protocol-level handshakes that validate state nonces inline.
  61. ("GET", "/api/v1/auth/oidc/authorize/{provider_id}"),
  62. ("GET", "/api/v1/auth/oidc/callback"),
  63. ("POST", "/api/v1/auth/oidc/exchange"),
  64. # 2FA send + verify — issued after password check; pre-auth token in cookie is the auth.
  65. ("POST", "/api/v1/auth/2fa/email/send"),
  66. ("POST", "/api/v1/auth/2fa/verify"),
  67. # Forgot-password (anonymous request) + confirm (signed token in the URL).
  68. ("POST", "/api/v1/auth/forgot-password"),
  69. ("POST", "/api/v1/auth/forgot-password/confirm"),
  70. # ---- HTTP API: signed-URL routes (token in path is the auth) ----
  71. # Signed download URLs — token in path validated by the handler.
  72. ("GET", "/api/v1/archives/{archive_id}/dl/{token}/{filename}"),
  73. ("GET", "/api/v1/archives/{archive_id}/source-dl/{token}/{filename}"),
  74. ("GET", "/api/v1/library/files/{file_id}/dl/{token}/{filename}"),
  75. # Obico cached frame — one-time nonce embedded in <img> tags.
  76. ("GET", "/api/v1/obico/cached-frame/{nonce}"),
  77. # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.
  78. ("GET", "/api/v1/makerworld/thumbnail"),
  79. # ---- HTTP API: operational + UI-bootstrap (no sensitive data) ----
  80. # Operational liveness probe — minimal payload, used by container orchestrators.
  81. ("GET", "/health"),
  82. # Prometheus metrics — gated by its own bearer token check (constant-time post-I2).
  83. ("GET", "/api/v1/metrics"),
  84. # UI bootstrap — defaults for sidebar order and ui-preferences are public defaults that ship with the app.
  85. ("GET", "/api/v1/settings/default-sidebar-order"),
  86. ("GET", "/api/v1/settings/ui-preferences"),
  87. # Slicer printer-models — static catalog, no user data.
  88. ("GET", "/api/v1/slicer/printer-models"),
  89. # Current Bambuddy version — public info (already visible in HTTP response headers + Docker tags).
  90. ("GET", "/api/v1/updates/version"),
  91. # Webhook routes — auth lives inside the handler via get_api_key() + check_permission(), not as a Depends.
  92. # Once they all migrate to standard auth deps these entries come out; for now exempting the file.
  93. ("GET", "/api/v1/webhook/printer/{printer_id}/status"),
  94. ("GET", "/api/v1/webhook/queue"),
  95. ("POST", "/api/v1/webhook/printer/{printer_id}/cancel"),
  96. ("POST", "/api/v1/webhook/printer/{printer_id}/start"),
  97. ("POST", "/api/v1/webhook/printer/{printer_id}/stop"),
  98. ("POST", "/api/v1/webhook/queue/add"),
  99. # ---- Static / SPA routes (not user data) ----
  100. ("GET", "/"),
  101. ("GET", "/manifest.json"),
  102. ("GET", "/sw-register.js"),
  103. ("GET", "/sw.js"),
  104. ("GET", "/gcode-viewer/"),
  105. ("GET", "/gcode-viewer/{file_path:path}"),
  106. # SPA catch-all — serves index.html for client-side routing. No backend data path.
  107. ("GET", "/{full_path:path}"),
  108. # ---- WebSocket routes ----
  109. # /ws performs an inline ``verify_websocket_token`` check before
  110. # ``accept()`` (GHSA-r2qv WS fix). The qualname matches one of the
  111. # auth-bearing patterns above, so this entry is informational — the
  112. # walker recognises the inline check as auth.
  113. }
  114. )
  115. def _walk_dependant_qualnames(dependant) -> list[str]:
  116. """Flatten the dependant tree to a list of callable qualnames."""
  117. names: list[str] = []
  118. if dependant is None:
  119. return names
  120. if dependant.call:
  121. names.append(getattr(dependant.call, "__qualname__", "?"))
  122. for sub in dependant.dependencies:
  123. names.extend(_walk_dependant_qualnames(sub))
  124. return names
  125. def _has_auth_dep(dependant) -> bool:
  126. """True if any callable in the dependant tree matches an auth pattern."""
  127. return any(any(p in qn for p in _AUTH_QUALNAME_PATTERNS) for qn in _walk_dependant_qualnames(dependant))
  128. def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
  129. """True if the websocket endpoint reads its source uses ``verify_websocket_token``.
  130. WebSocket routes don't pass auth via the standard Depends machinery
  131. (the WebSocket handshake doesn't carry headers), so the auth check
  132. lives inline in the endpoint body. We confirm by inspecting the
  133. endpoint function's source text — looking for an actual call to
  134. ``verify_websocket_token``. A docstring-only mention would NOT
  135. satisfy this check (we look for a call-shaped pattern, not a
  136. substring).
  137. """
  138. import inspect
  139. try:
  140. source = inspect.getsource(route.endpoint)
  141. except (OSError, TypeError):
  142. return False
  143. return bool(re.search(r"\bverify_websocket_token\s*\(", source))
  144. @pytest.mark.unit
  145. def test_routes_have_explicit_auth_deps() -> None:
  146. """SEC-AUTH-1 (SECURITY.md): every API route has an auth dep or is in the public allowlist.
  147. Walks both ``APIRoute`` (HTTP) and ``APIWebSocketRoute`` (WS)
  148. objects on the live FastAPI app. For each, asserts that at least
  149. one of the resolved Depends in the dependant tree matches an auth-
  150. bearing qualname, OR that the (method, path) pair is in the
  151. explicit public-route allowlist, OR (for WebSocket routes) that
  152. the endpoint performs an inline ``verify_websocket_token`` check.
  153. Failure means a new route is reachable anonymously without being
  154. documented as such — the GHSA-gc24 / GHSA-r2qv shape.
  155. """
  156. failures: list[str] = []
  157. for route in app.routes:
  158. if isinstance(route, APIRoute):
  159. method = sorted(route.methods)[0] if route.methods else "GET"
  160. if _has_auth_dep(route.dependant):
  161. continue
  162. if (method, route.path) in _PUBLIC_ROUTES:
  163. continue
  164. failures.append(f" {method:7} {route.path} → no auth dep, not in _PUBLIC_ROUTES allowlist")
  165. elif isinstance(route, APIWebSocketRoute):
  166. if _has_auth_dep(route.dependant):
  167. continue
  168. if _ws_endpoint_does_inline_token_check(route):
  169. continue
  170. if ("WS", route.path) in _PUBLIC_ROUTES:
  171. continue
  172. failures.append(f" WS {route.path} → no auth dep, no inline token check, not in _PUBLIC_ROUTES")
  173. assert not failures, (
  174. "Routes without an auth dependency that aren't in the public allowlist. "
  175. "Either add a ``Depends(require_*)`` to the route OR add the (method, path) "
  176. "to ``_PUBLIC_ROUTES`` with a comment justifying why anonymous access is safe. "
  177. "See SECURITY.md rule 1 'Allowlist over denylist' (route allowlist sub-section).\n\n" + "\n".join(failures)
  178. )
  179. @pytest.mark.unit
  180. def test_public_routes_allowlist_matches_real_routes() -> None:
  181. """Drift-detection: every (method, path) in ``_PUBLIC_ROUTES`` must exist on the app.
  182. If a route is renamed or removed, the entry for it in the allowlist
  183. becomes dead — a residual rubber-stamp that does nothing but leaves
  184. the impression that the route still has anonymous access. This test
  185. flags those.
  186. """
  187. real_routes: set[tuple[str, str]] = set()
  188. for route in app.routes:
  189. if isinstance(route, APIRoute):
  190. method = sorted(route.methods)[0] if route.methods else "GET"
  191. real_routes.add((method, route.path))
  192. elif isinstance(route, APIWebSocketRoute):
  193. real_routes.add(("WS", route.path))
  194. stale = sorted(_PUBLIC_ROUTES - real_routes)
  195. assert not stale, (
  196. "_PUBLIC_ROUTES contains entries that no longer match any real route. "
  197. "Remove these stale entries (the route was renamed, removed, or its method changed).\n\n"
  198. + "\n".join(f" {m:7} {p}" for m, p in stale)
  199. )