test_route_auth_coverage.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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. # Printer download target — validates a short-lived, single-use token
  76. # bound to the printer ID before streaming its prepared bundle.
  77. ("GET", "/api/v1/printers/{printer_id}/files/dl/{token}/{filename}"),
  78. # Attached archive media target — validates a token bound to the
  79. # archive ID before returning the local timelapse.
  80. ("GET", "/api/v1/archives/{archive_id}/media/dl/{token}/{filename}"),
  81. # Obico cached frame — one-time nonce embedded in <img> tags.
  82. ("GET", "/api/v1/obico/cached-frame/{nonce}"),
  83. # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.
  84. ("GET", "/api/v1/makerworld/thumbnail"),
  85. # ---- HTTP API: operational + UI-bootstrap (no sensitive data) ----
  86. # Operational liveness probe — minimal payload, used by container orchestrators.
  87. ("GET", "/health"),
  88. # Prometheus metrics — gated by its own bearer token check (constant-time post-I2).
  89. ("GET", "/api/v1/metrics"),
  90. # UI bootstrap — defaults for sidebar order and ui-preferences are public defaults that ship with the app.
  91. ("GET", "/api/v1/settings/default-sidebar-order"),
  92. ("GET", "/api/v1/settings/ui-preferences"),
  93. # Appliance locale defaults — read by the i18n bootstrap BEFORE auth might be set up.
  94. # Contents are user-set hostname/timezone/locale from the firstboot wizard (no secrets);
  95. # the file is absent on non-appliance installs, in which case every field is null.
  96. ("GET", "/api/v1/system/appliance"),
  97. # Slicer printer-models — static catalog, no user data.
  98. ("GET", "/api/v1/slicer/printer-models"),
  99. # Current Bambuddy version — public info (already visible in HTTP response headers + Docker tags).
  100. ("GET", "/api/v1/updates/version"),
  101. # Webhook routes — auth lives inside the handler via get_api_key() + check_permission(), not as a Depends.
  102. # Once they all migrate to standard auth deps these entries come out; for now exempting the file.
  103. ("GET", "/api/v1/webhook/printer/{printer_id}/status"),
  104. ("GET", "/api/v1/webhook/queue"),
  105. ("POST", "/api/v1/webhook/printer/{printer_id}/cancel"),
  106. ("POST", "/api/v1/webhook/printer/{printer_id}/start"),
  107. ("POST", "/api/v1/webhook/printer/{printer_id}/stop"),
  108. ("POST", "/api/v1/webhook/queue/add"),
  109. # ---- Static / SPA routes (not user data) ----
  110. ("GET", "/"),
  111. ("GET", "/manifest.json"),
  112. ("GET", "/sw-register.js"),
  113. ("GET", "/sw.js"),
  114. # SPA catch-all — serves index.html for client-side routing. No backend data path.
  115. ("GET", "/{full_path:path}"),
  116. # ---- WebSocket routes ----
  117. # /ws performs an inline ``verify_websocket_token`` check before
  118. # ``accept()`` (GHSA-r2qv WS fix). The qualname matches one of the
  119. # auth-bearing patterns above, so this entry is informational — the
  120. # walker recognises the inline check as auth.
  121. }
  122. )
  123. def _walk_dependant_qualnames(dependant) -> list[str]:
  124. """Flatten the dependant tree to a list of callable qualnames."""
  125. names: list[str] = []
  126. if dependant is None:
  127. return names
  128. if dependant.call:
  129. names.append(getattr(dependant.call, "__qualname__", "?"))
  130. for sub in dependant.dependencies:
  131. names.extend(_walk_dependant_qualnames(sub))
  132. return names
  133. def _has_auth_dep(dependant) -> bool:
  134. """True if any callable in the dependant tree matches an auth pattern."""
  135. return any(any(p in qn for p in _AUTH_QUALNAME_PATTERNS) for qn in _walk_dependant_qualnames(dependant))
  136. def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
  137. """True if the websocket endpoint reads its source uses ``verify_websocket_token``.
  138. WebSocket routes don't pass auth via the standard Depends machinery
  139. (the WebSocket handshake doesn't carry headers), so the auth check
  140. lives inline in the endpoint body. We confirm by inspecting the
  141. endpoint function's source text — looking for an actual call to
  142. ``verify_websocket_token``. A docstring-only mention would NOT
  143. satisfy this check (we look for a call-shaped pattern, not a
  144. substring).
  145. """
  146. import inspect
  147. try:
  148. source = inspect.getsource(route.endpoint)
  149. except (OSError, TypeError):
  150. return False
  151. return bool(re.search(r"\bverify_websocket_token\s*\(", source))
  152. @pytest.mark.unit
  153. def test_routes_have_explicit_auth_deps() -> None:
  154. """SEC-AUTH-1 (SECURITY.md): every API route has an auth dep or is in the public allowlist.
  155. Walks both ``APIRoute`` (HTTP) and ``APIWebSocketRoute`` (WS)
  156. objects on the live FastAPI app. For each, asserts that at least
  157. one of the resolved Depends in the dependant tree matches an auth-
  158. bearing qualname, OR that the (method, path) pair is in the
  159. explicit public-route allowlist, OR (for WebSocket routes) that
  160. the endpoint performs an inline ``verify_websocket_token`` check.
  161. Failure means a new route is reachable anonymously without being
  162. documented as such — the GHSA-gc24 / GHSA-r2qv shape.
  163. """
  164. failures: list[str] = []
  165. for route in app.routes:
  166. if isinstance(route, APIRoute):
  167. method = sorted(route.methods)[0] if route.methods else "GET"
  168. if _has_auth_dep(route.dependant):
  169. continue
  170. if (method, route.path) in _PUBLIC_ROUTES:
  171. continue
  172. failures.append(f" {method:7} {route.path} → no auth dep, not in _PUBLIC_ROUTES allowlist")
  173. elif isinstance(route, APIWebSocketRoute):
  174. if _has_auth_dep(route.dependant):
  175. continue
  176. if _ws_endpoint_does_inline_token_check(route):
  177. continue
  178. if ("WS", route.path) in _PUBLIC_ROUTES:
  179. continue
  180. failures.append(f" WS {route.path} → no auth dep, no inline token check, not in _PUBLIC_ROUTES")
  181. assert not failures, (
  182. "Routes without an auth dependency that aren't in the public allowlist. "
  183. "Either add a ``Depends(require_*)`` to the route OR add the (method, path) "
  184. "to ``_PUBLIC_ROUTES`` with a comment justifying why anonymous access is safe. "
  185. "See SECURITY.md rule 1 'Allowlist over denylist' (route allowlist sub-section).\n\n" + "\n".join(failures)
  186. )
  187. @pytest.mark.unit
  188. def test_public_routes_allowlist_matches_real_routes() -> None:
  189. """Drift-detection: every (method, path) in ``_PUBLIC_ROUTES`` must exist on the app.
  190. If a route is renamed or removed, the entry for it in the allowlist
  191. becomes dead — a residual rubber-stamp that does nothing but leaves
  192. the impression that the route still has anonymous access. This test
  193. flags those.
  194. """
  195. real_routes: set[tuple[str, str]] = set()
  196. for route in app.routes:
  197. if isinstance(route, APIRoute):
  198. method = sorted(route.methods)[0] if route.methods else "GET"
  199. real_routes.add((method, route.path))
  200. elif isinstance(route, APIWebSocketRoute):
  201. real_routes.add(("WS", route.path))
  202. stale = sorted(_PUBLIC_ROUTES - real_routes)
  203. assert not stale, (
  204. "_PUBLIC_ROUTES contains entries that no longer match any real route. "
  205. "Remove these stale entries (the route was renamed, removed, or its method changed).\n\n"
  206. + "\n".join(f" {m:7} {p}" for m, p in stale)
  207. )