-
Notifications
You must be signed in to change notification settings - Fork 0
feat(debug): protect /debug behind Cloudflare Access + token fallback #5522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5b79b61
34c8631
b0a65a5
f167b7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,9 @@ | |
| import time | ||
| from collections import Counter | ||
| from datetime import datetime, timedelta, timezone | ||
| from functools import lru_cache | ||
|
|
||
| import jwt as pyjwt | ||
| from fastapi import APIRouter, Depends, Header, HTTPException, Request, status | ||
| from pydantic import BaseModel | ||
| from sqlalchemy import text | ||
|
|
@@ -22,14 +24,69 @@ | |
| router = APIRouter(prefix="/debug", tags=["debug"]) | ||
|
|
||
|
|
||
| def require_admin(x_admin_token: str | None = Header(default=None)) -> None: | ||
| """Gate sensitive /debug/* endpoints behind a shared secret. | ||
| @lru_cache(maxsize=1) | ||
| def _jwks_client() -> pyjwt.PyJWKClient | None: | ||
| """Lazy, process-wide JWKS client for Cloudflare Access JWT verification. | ||
|
|
||
| Without this gate, /debug/status and /debug/ping reflect quality scores, | ||
| weakness aggregates, and DB latency to the public internet. When | ||
| settings.admin_token is unset the endpoint is disabled (503), so a | ||
| misconfigured prod deploy fails closed instead of fails open. | ||
| PyJWKClient caches keys per instance; we cache the instance itself so a | ||
| single Cloud Run worker only fetches the JWKS endpoint once at first | ||
| /debug request after cold start. | ||
| """ | ||
| if not settings.cf_access_team_domain: | ||
| return None | ||
| return pyjwt.PyJWKClient(f"https://{settings.cf_access_team_domain}/cdn-cgi/access/certs") | ||
|
|
||
|
|
||
| def _verify_cf_access_jwt(token: str) -> str | None: | ||
| """Verify a Cloudflare Access JWT and return the authenticated email. | ||
|
|
||
| Returns None when the JWT path is unconfigured, the signature is invalid, | ||
| the token is expired, or the aud/iss claims don't match. | ||
| """ | ||
| client = _jwks_client() | ||
| if client is None or not settings.cf_access_aud: | ||
| return None | ||
| try: | ||
| signing_key = client.get_signing_key_from_jwt(token) | ||
| claims = pyjwt.decode( | ||
| token, | ||
| signing_key.key, | ||
| algorithms=["RS256"], | ||
| audience=settings.cf_access_aud, | ||
| issuer=f"https://{settings.cf_access_team_domain}", | ||
| ) | ||
| except pyjwt.PyJWTError: | ||
| return None | ||
| email = claims.get("email") | ||
| return email if isinstance(email, str) else None | ||
|
|
||
|
|
||
| def require_admin( | ||
| x_admin_token: str | None = Header(default=None), | ||
| cf_access_jwt: str | None = Header(default=None, alias="Cf-Access-Jwt-Assertion"), | ||
| ) -> None: | ||
| """Gate sensitive /debug/* endpoints behind Cloudflare Access OR a shared secret. | ||
|
|
||
| Two paths: | ||
| 1. Browser path — Cloudflare Access verifies a Google identity at the edge, | ||
| forwards the request with `Cf-Access-Jwt-Assertion`. We verify the JWT | ||
| against Cloudflare's JWKS and check the email is on the allow-list. | ||
| 2. Token path — `X-Admin-Token` against `settings.admin_token`. Used by CI, | ||
| local dev, and break-glass access via the Cloud Run direct URL (which | ||
| bypasses Cloudflare). | ||
|
|
||
| Without `settings.admin_token` configured the token path is disabled (503), | ||
| so a misconfigured prod deploy without Cloudflare Access still fails closed. | ||
| """ | ||
| if cf_access_jwt: | ||
| email = _verify_cf_access_jwt(cf_access_jwt) | ||
| if email and email in settings.admin_allowed_emails: | ||
| return | ||
| if email: | ||
| raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User {email} not authorized") | ||
| # Invalid JWT (signature/aud/iss/expiry) — fall through to token path so | ||
|
Comment on lines
+81
to
+87
|
||
| # a misconfigured edge never strands the operator without break-glass access. | ||
|
|
||
| expected = settings.admin_token | ||
| if not expected: | ||
| raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Debug endpoints not configured") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,10 +160,14 @@ function pingColor(ms: number): string { | |
| return colors.error; | ||
| } | ||
|
|
||
| // Admin-token storage. /debug endpoints require X-Admin-Token in prod | ||
| // (require_admin gate, api/routers/debug.py); we keep the token in | ||
| // sessionStorage so it survives reloads of the same tab without persisting | ||
| // across browser sessions. | ||
| // Admin auth for /debug endpoints (require_admin in api/routers/debug.py). | ||
| // Two paths: | ||
| // - Cloudflare Access cookie set on .anyplot.ai → forwarded as Cf-Access-Jwt-Assertion | ||
| // to api.anyplot.ai. credentials: 'include' is required for the cookie to | ||
| // travel cross-origin to the API. | ||
| // - X-Admin-Token header as a fallback (CI, break-glass, local dev). Stored | ||
| // in sessionStorage so it survives reloads of the same tab without | ||
| // persisting across browser sessions. | ||
| const ADMIN_TOKEN_KEY = 'anyplot.adminToken'; | ||
| const readAdminToken = (): string => { | ||
| try { return sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? ''; } catch { return ''; } | ||
|
|
@@ -176,7 +180,10 @@ const clearAdminToken = (): void => { | |
| }; | ||
|
|
||
| const adminFetch = (url: string, token: string): Promise<Response> => | ||
| fetch(url, token ? { headers: { 'X-Admin-Token': token } } : undefined); | ||
| fetch(url, { | ||
| credentials: 'include', | ||
| headers: token ? { 'X-Admin-Token': token } : undefined, | ||
| }); | ||
|
Comment on lines
182
to
+186
|
||
|
|
||
| export function DebugPage() { | ||
| const [data, setData] = useState<DebugStatus | null>(null); | ||
|
|
@@ -200,9 +207,17 @@ export function DebugPage() { | |
| setLoading(true); | ||
| setError(null); | ||
| adminFetch(`${API_URL}/debug/status`, adminToken) | ||
| .then(r => { | ||
| if (r.status === 401 || r.status === 503) { | ||
| .then(async r => { | ||
| // 403 is the Cloudflare Access JWT path's denial: a signed-in Google | ||
| // account that isn't on the admin_allowed_emails allow-list. Surface | ||
| // it on the auth-required screen with the server's message so the | ||
| // user knows to sign in with a different account or ask for access. | ||
| if (r.status === 401 || r.status === 403 || r.status === 503) { | ||
| setAuthRequired(true); | ||
| if (r.status === 403) { | ||
| const body = await r.json().catch(() => ({})); | ||
| throw new Error(body?.message || 'this account is not authorized for /debug'); | ||
| } | ||
| throw new Error(r.status === 503 ? 'admin auth not configured on server' : 'admin token required'); | ||
| } | ||
| if (!r.ok) throw new Error(`${r.status}`); | ||
|
|
@@ -301,14 +316,14 @@ export function DebugPage() { | |
| <Box sx={{ py: 4, maxWidth: 420, mx: 'auto' }}> | ||
| <SectionHeader prompt="❯" title={<em>debug · admin auth</em>} /> | ||
| <Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xs, color: semanticColors.mutedText, mb: 2 }}> | ||
| {error || 'admin token required'} | ||
| {error || 'sign in via your browser session, or paste an admin token as a fallback.'} | ||
| </Typography> | ||
| <Box component="form" onSubmit={handleTokenSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> | ||
| <Box | ||
| component="input" | ||
| type="password" | ||
| autoFocus | ||
| placeholder="X-Admin-Token" | ||
| placeholder="Admin token (fallback)" | ||
| value={tokenInput} | ||
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenInput(e.target.value)} | ||
| sx={nativeControlSx} | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,8 +5,9 @@ | |||||||||||||||||||
| defaults, and documentation. | ||||||||||||||||||||
| """ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from typing import Optional | ||||||||||||||||||||
| from typing import Any, Optional | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from pydantic import field_validator | ||||||||||||||||||||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -156,6 +157,39 @@ class Settings(BaseSettings): | |||||||||||||||||||
| weakness aggregates, and DB latency to the public internet. Set via Secret | ||||||||||||||||||||
| Manager in Cloud Run; pass via the `X-Admin-Token` request header.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| cf_access_team_domain: Optional[str] = None | ||||||||||||||||||||
| """Cloudflare Access team domain, e.g. `anyplot.cloudflareaccess.com`. | ||||||||||||||||||||
| Used as the JWT issuer and for fetching JWKS keys at | ||||||||||||||||||||
| `https://{team_domain}/cdn-cgi/access/certs`. When unset, the JWT path in | ||||||||||||||||||||
| require_admin is skipped (token-only mode).""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| cf_access_aud: Optional[str] = None | ||||||||||||||||||||
| """Cloudflare Access Application AUD tag (UUID from the Zero Trust | ||||||||||||||||||||
| dashboard). Validated as the JWT `aud` claim.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| admin_allowed_emails: list[str] = [] | ||||||||||||||||||||
| """Email addresses allowed to authenticate via Cloudflare Access for | ||||||||||||||||||||
| /debug/* endpoints. Defaults to an empty list — must be set explicitly | ||||||||||||||||||||
| in production. Accepts either a JSON array (`["a@b.com","c@d.com"]`) | ||||||||||||||||||||
| or a comma-separated string (`a@b.com,c@d.com`) when set via env var.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| @field_validator("admin_allowed_emails", mode="before") | ||||||||||||||||||||
| @classmethod | ||||||||||||||||||||
| def _parse_admin_allowed_emails(cls, value: Any) -> Any: | ||||||||||||||||||||
| """Allow `ADMIN_ALLOWED_EMAILS=foo@bar.com,baz@qux.com` in addition to | ||||||||||||||||||||
| the JSON-array form pydantic-settings expects by default. A bare | ||||||||||||||||||||
| string with no commas is treated as a single-element list.""" | ||||||||||||||||||||
| import json | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if isinstance(value, str): | ||||||||||||||||||||
| stripped = value.strip() | ||||||||||||||||||||
| if not stripped: | ||||||||||||||||||||
| return [] | ||||||||||||||||||||
| if stripped.startswith("["): | ||||||||||||||||||||
| return json.loads(stripped) | ||||||||||||||||||||
|
||||||||||||||||||||
| return json.loads(stripped) | |
| try: | |
| return json.loads(stripped) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError( | |
| "Invalid ADMIN_ALLOWED_EMAILS format. Use either a JSON array " | |
| 'like ["a@b.com", "c@d.com"] or a comma-separated string like ' | |
| '"a@b.com,c@d.com".' | |
| ) from exc |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says that with no email in
_ADMIN_ALLOWED_EMAILS“only the X-Admin-Token fallback is active”. With the currentrequire_adminlogic, a valid Cloudflare JWT with an unlisted email yields a 403 and does not fall through to the token path, so the token fallback is not active for requests that includeCf-Access-Jwt-Assertion. Please update the comment to match the implemented behavior (or adjust the code to match the comment).