Harden portal: shared-secret auth for key injector + token-authenticated webhook URL

This commit is contained in:
inference-bot committed 2026-09-05 21:42:27 -06:00
1 parent d6605992bc
commit ce7d9f3220
1 file changed
+36 -10
+36 -10
View File
@@ -17,6 +17,7 @@ import os
import json
import logging
import sqlite3
import secrets
import httpx
from fastapi import FastAPI, Request, Response, HTTPException
@@ -34,6 +35,16 @@ LITELLM_BASE = os.environ.get("LITELLM_BASE", "https://gateway.inference.coop")
LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "")
# Shared secret that LibreChat sends as a header on every request, so the
# portal can verify the request genuinely came through LibreChat (which is
# behind Cloudron SSO) rather than a direct, spoofed request.
PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "")
# Secret token required in the Open Collective webhook URL path. Open
# Collective's generic webhooks are not HMAC-signed, so a secret in the URL
# is the standard way to authenticate them.
WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "")
# Monthly credit budget (in USD of tokens) for all members.
# Single sliding-scale tier: everyone gets the same $15/month in credits,
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
@@ -48,6 +59,16 @@ MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "")
DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db")
def _verify_portal_secret(request: Request) -> None:
"""Reject requests that didn't come through LibreChat (shared secret)."""
if not PORTAL_SECRET:
# If no secret is configured, refuse to inject keys (fail closed).
raise HTTPException(503, "Portal secret not configured")
provided = request.headers.get("x-portal-secret", "")
if not secrets.compare_digest(provided, PORTAL_SECRET):
raise HTTPException(401, "Invalid portal secret")
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.execute(
@@ -192,17 +213,18 @@ async def litellm_disable_key(key_token: str) -> None:
# --- A. Open Collective webhook ---
@app.post("/webhook/opencollective")
async def opencollective_webhook(request: Request):
"""Handle Open Collective membership events."""
payload = await request.json()
@app.post("/webhook/opencollective/{token}")
async def opencollective_webhook(request: Request, token: str):
"""Handle Open Collective membership events.
# Verify webhook secret if configured
if OPENCOLLECTIVE_SECRET:
sig = request.headers.get("x-oc-signature", "")
# TODO: verify HMAC signature
if not sig:
raise HTTPException(401, "Missing signature")
Authenticated by a secret token in the URL path (Open Collective's
generic webhooks are not HMAC-signed, so a secret URL is the standard
way to authenticate them).
"""
if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):
raise HTTPException(401, "Invalid webhook token")
payload = await request.json()
event_type = payload.get("type", "")
data = payload.get("data", {})
@@ -259,6 +281,10 @@ async def list_models():
@app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def inject_key(request: Request, path: str):
"""Read the member's email from a header, inject their key, forward to LiteLLM."""
# Verify the request came through LibreChat (shared secret), so a direct
# caller can't spoof the X-User-Email header and use another member's key.
_verify_portal_secret(request)
email = request.headers.get("x-user-email", "")
if not email:
raise HTTPException(401, "No member identity (x-user-email header)")