Harden portal: shared-secret auth for key injector + token-authenticated webhook URL
This commit is contained in:
1 parent
d6605992bc
commit
ce7d9f3220
1 file changed
+36
-10
+36
-10
@@ -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)")
|
||||
|
||||
Reference in new issue
Block a user