diff --git a/CloudronManifest.json b/CloudronManifest.json index 8300ad0..6f0ab54 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -27,5 +27,5 @@ "manifestVersion": 2, "website": "https://inference.coop", "contactEmail": "info@inference.coop", - "dockerImage": "registry.inference.coop/co-op/member-portal:20261026-9495daa" + "dockerImage": "registry.inference.coop/co-op/member-portal:20261026-e55d8a3" } diff --git a/app/main.py b/app/main.py index f86f00d..f9e21e3 100644 --- a/app/main.py +++ b/app/main.py @@ -98,6 +98,17 @@ BROKER_SECRET = os.environ.get("BROKER_SECRET", "") # is the standard way to authenticate them. WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "") +# SECURITY (2026-10-27 member disclosure): the /admin/* token is SEPARATE from +# WEBHOOK_TOKEN. WEBHOOK_TOKEN must sit in the Open Collective webhook URL (OC's +# generic webhooks cannot send headers), so anything that learns that URL — an OC +# admin, OC's logs, this app's own access logs, a Referer — used to gain portal +# admin. ADMIN_TOKEN is only ever sent as a header and never appears in a URL. +# Cutover: while ADMIN_TOKEN_LEGACY is not "0" we also accept WEBHOOK_TOKEN on +# /admin/* so the admin panel, the scheduler and the digest scripts keep working. +# Set ADMIN_TOKEN everywhere, then set ADMIN_TOKEN_LEGACY=0 and rotate both. +ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") +ADMIN_TOKEN_LEGACY = os.environ.get("ADMIN_TOKEN_LEGACY", "1") != "0" + OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2" OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative") @@ -534,16 +545,21 @@ def _verify_portal_secret(request: Request) -> None: def _verify_admin_token(request: Request) -> None: - """Authenticate admin/internal endpoints via the X-Admin-Token header. + """Authorize a /admin/* caller (SECURITY 2026-10-27). - Replaces the old token-in-URL pattern (tokens in URLs leak into access - logs and Referer headers). Callers pass the admin token in a header instead. + Admin auth is a HEADER, never a URL token: the webhook URL token lives in + places we do not control, and it used to unlock all of /admin/*. """ - if not WEBHOOK_TOKEN: - raise HTTPException(503, "Admin token not configured") provided = request.headers.get("x-admin-token", "") - if not secrets.compare_digest(provided, WEBHOOK_TOKEN): - raise HTTPException(401, "Invalid admin token") + if ADMIN_TOKEN and secrets.compare_digest(provided, ADMIN_TOKEN): + return + if ADMIN_TOKEN_LEGACY and WEBHOOK_TOKEN and secrets.compare_digest(provided, WEBHOOK_TOKEN): + logger.warning( + "admin auth: caller used the legacy WEBHOOK_TOKEN — move it to" + " ADMIN_TOKEN, then set ADMIN_TOKEN_LEGACY=0" + ) + return + raise HTTPException(403, "Forbidden") def get_db() -> sqlite3.Connection: @@ -1851,6 +1867,51 @@ async def litellm_disable_key(key_token: str) -> None: ) +async def litellm_team_id_for_email(email: str) -> str | None: + """The team_id of a member's budget team, or None if they have none.""" + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=15.0, read=60.0, write=15.0, pool=15.0)) as client: + r = await client.get(f"{LITELLM_BASE}/team/list", + headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}) + r.raise_for_status() + for t in r.json(): + if t.get("team_alias") == f"member:{email}": + return t["team_id"] + return None + + +async def revoke_member_litellm_access(email: str) -> bool: + """Cut a deactivated member's gateway access (SECURITY 2026-10-27). + + Zeroing the team budget blocks every key under it: member-managed keys carry + no max_budget of their own (verified live 2026-10-27), so they inherit the + team's. Deleting the key instead would break reactivation, which deliberately + reuses the stored sk- token — and get_or_create_team does NOT update the + budget of an existing team, so reactivation paths must re-sync explicitly. + + Best-effort by design: a failure here must not stop deactivation from + completing, but it must be loud, because the member keeps access until it + succeeds. + """ + try: + team_id = await litellm_team_id_for_email(email) + if not team_id: + logger.info("Revoke: %s has no gateway team; nothing to zero", email) + return True + await litellm_update_team_budget(team_id, 0.0) + logger.info("Revoke: %s gateway budget zeroed (team %s)", email, team_id) + return True + except Exception as e: + logger.error("Revoke: FAILED to zero gateway budget for %s: %s", email, e) + try: + send_email("info@inference.coop", + "Deactivation did not revoke gateway access", + f"{email} was deactivated but their LiteLLM team budget could not be zeroed:\n\n{e}\n\n" + "Their existing API keys may still work. Fix by hand: set the team's max_budget to 0.\n") + except Exception: + pass + return False + + # --- Broker: member-managed API keys (scoped to the member's team) --- def _api_key_alias(email: str, name: str) -> str: @@ -2034,10 +2095,33 @@ async def opencollective_webhook(request: Request, token: str): # public order API. Fallback: webhook totalAmount (slightly # overgrants if a tip was included; logged). tip_free_cents = await get_order_amount(order.get("idV2")) - if tip_free_cents is not None: - amount = round(tip_free_cents / 100.0, 2) - else: - amount = round(float(amount_cents) / 100.0, 2) + if tip_free_cents is None: + # SECURITY (2026-10-27): do NOT fall back to the payload's own + # amount. This route is authenticated only by a URL token, so a + # forged POST naming its own totalAmount used to mint credits for + # any slug. Treat the payload as a hint; require a resolvable OC + # order. A forged id cannot resolve; a transient OC failure holds + # for manual reconciliation instead of granting blind. + claimed = round(float(amount_cents) / 100.0, 2) + logger.warning( + "Credit pack REJECTED: OC could not resolve order %r (slug=%s, payload claimed $%.2f)" + " — no credits granted", + order.get("idV2"), slug, claimed, + ) + try: + send_email( + "info@inference.coop", + "Credit pack could not be verified — no credits granted", + f"A credit-pack webhook arrived for {slug} claiming ${claimed:.2f}, but\n" + f"Open Collective's order API could not resolve order id {order.get('idV2')!r}.\n\n" + "No credits were granted: the payload's amount is not trusted. If this was a\n" + "real purchase, confirm the order in Open Collective and grant the credits\n" + "by hand; if it was not, this was an attempt to mint credits.\n", + ) + except Exception as e: + logger.warning("Credit pack rejection: operator email failed: %s", e) + return JSONResponse({"status": "unverified_order_held", "slug": slug}) + amount = round(tip_free_cents / 100.0, 2) charged = round(float(amount_cents) / 100.0, 2) if charged > amount: logger.info( @@ -2131,6 +2215,9 @@ async def opencollective_webhook(request: Request, token: str): if first_payment: try: await provision_member(member["email"], member["name"], member["slug"]) + # A lapsed member's team budget was zeroed on deactivation and + # get_or_create_team does not restore it — re-apply explicitly. + await sync_team_budget_from_balance(member["email"]) except Exception as e: logger.warning("Webhook: failed to provision %s: %s", member["email"], e) try: @@ -2161,6 +2248,7 @@ async def opencollective_webhook(request: Request, token: str): if first_payment: try: await provision_member(org_email, name, slug) + await sync_team_budget_from_balance(org_email) except Exception as e: logger.warning("Webhook: org re-provision failed for %s: %s", org_email, e) @@ -2200,6 +2288,7 @@ async def opencollective_webhook(request: Request, token: str): await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID) # Mark inactive in our DB (key injector will refuse requests) deactivate_member(member["email"]) + await revoke_member_litellm_access(member["email"]) await listmonk_sync(member["email"], subscribe=False) await sync_loomio_memberships() await sync_matrix_space() @@ -3366,6 +3455,7 @@ async def reconcile_memberships() -> dict: if INACTIVE_GROUP_ID and user_id: await cloudron_set_group(user_id, INACTIVE_GROUP_ID) deactivate_member(email) + await revoke_member_litellm_access(email) await listmonk_sync(email, subscribe=False) deactivated_count += 1 logger.info("Sweep: deactivated %s", email) diff --git a/sweep.sh b/sweep.sh index 191b7ef..4ec5821 100644 --- a/sweep.sh +++ b/sweep.sh @@ -5,19 +5,22 @@ # the portal against the live Open Collective member list: provisions missing # members, deactivates lapsed members, and syncs Loomio. # -# The WEBHOOK_TOKEN env var is shared with the main app (scheduler runs in the -# same launch environment), so we use it to authenticate the call. +# The ADMIN_TOKEN env var is shared with the main app (scheduler runs in the +# same launch environment), so we use it to authenticate the call. Admin auth is +# a header, never a URL token (security fix 2026-10-27). WEBHOOK_TOKEN is only a +# fallback until this caller is fully migrated. # # Uses Python (guaranteed present in the base image) rather than curl. set -e -if [ -z "$WEBHOOK_TOKEN" ]; then - echo "sweep: WEBHOOK_TOKEN not set; aborting" >&2 +ADMIN_TOKEN="${ADMIN_TOKEN:-$WEBHOOK_TOKEN}" +if [ -z "$ADMIN_TOKEN" ]; then + echo "sweep: neither ADMIN_TOKEN nor WEBHOOK_TOKEN is set; aborting" >&2 exit 1 fi -python3 - "$WEBHOOK_TOKEN" <<'PY' +python3 - "$ADMIN_TOKEN" <<'PY' import sys, urllib.request, urllib.error token = sys.argv[1]