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 json
import logging import logging
import sqlite3 import sqlite3
import secrets
import httpx import httpx
from fastapi import FastAPI, Request, Response, HTTPException 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", "") LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "") 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. # Monthly credit budget (in USD of tokens) for all members.
# Single sliding-scale tier: everyone gets the same $15/month in credits, # Single sliding-scale tier: everyone gets the same $15/month in credits,
# regardless of their $10/15/20 contribution. Governance decision (Loomio). # 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") 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: def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.execute( conn.execute(
@@ -192,17 +213,18 @@ async def litellm_disable_key(key_token: str) -> None:
# --- A. Open Collective webhook --- # --- A. Open Collective webhook ---
@app.post("/webhook/opencollective") @app.post("/webhook/opencollective/{token}")
async def opencollective_webhook(request: Request): async def opencollective_webhook(request: Request, token: str):
"""Handle Open Collective membership events.""" """Handle Open Collective membership events.
payload = await request.json()
# Verify webhook secret if configured Authenticated by a secret token in the URL path (Open Collective's
if OPENCOLLECTIVE_SECRET: generic webhooks are not HMAC-signed, so a secret URL is the standard
sig = request.headers.get("x-oc-signature", "") way to authenticate them).
# TODO: verify HMAC signature """
if not sig: if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):
raise HTTPException(401, "Missing signature") raise HTTPException(401, "Invalid webhook token")
payload = await request.json()
event_type = payload.get("type", "") event_type = payload.get("type", "")
data = payload.get("data", {}) data = payload.get("data", {})
@@ -259,6 +281,10 @@ async def list_models():
@app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def inject_key(request: Request, path: str): async def inject_key(request: Request, path: str):
"""Read the member's email from a header, inject their key, forward to LiteLLM.""" """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", "") email = request.headers.get("x-user-email", "")
if not email: if not email:
raise HTTPException(401, "No member identity (x-user-email header)") raise HTTPException(401, "No member identity (x-user-email header)")