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 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)")
|
||||||
|
|||||||
Reference in new issue
Block a user