From ce7d9f322001a632f6c900f6d1b83de4ca0dfbf0 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Sat, 5 Sep 2026 21:42:27 -0600 Subject: [PATCH] Harden portal: shared-secret auth for key injector + token-authenticated webhook URL --- app/main.py | 46 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index 2cd6ba6..8d8f41b 100644 --- a/app/main.py +++ b/app/main.py @@ -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)")