nexus-ai / api /security.py
nexus-deploy
deploy: sync backend b07a8d5
9c3ba60
Raw
History Blame Contribute Delete
7.61 kB
"""Phase 6AL β€” production lock-down middleware.
A single ``SecurityMiddleware`` enforces three rules so a public free-tier
deployment cannot be drained by random callers:
1. **Shared API key.** Every protected route requires the
``X-Nexus-Key`` header to match ``settings.NEXUS_API_KEY`` exactly.
The key is set as a Fly secret on the backend AND baked into the
frontend build via ``VITE_NEXUS_KEY``. The bundle isn't
cryptographically secret (anyone can read JS), but combined with
CORS + rate-limits below it makes abuse impractical.
2. **CORS-locked Origin check.** For browser requests we also verify
the ``Origin`` header matches ``settings.FRONTEND_URL``. Random
domains hitting the API (even with a stolen key) get 403.
3. **Per-IP rate limit.** A bounded sliding-window counter blocks any
single IP from making more than ``RATE_LIMIT_PER_HOUR`` calls per
hour to generation endpoints. Free-tier AI quotas survive abuse.
The middleware is **defense-in-depth, not cryptography**. A future phase
should add real user auth (Clerk / magic-link / Auth0) for finer-grained
access control; this layer is for invite-only free-tier launches.
Routes exempted from the API-key check (intentionally public):
* ``GET /api/health`` β€” load-balancer health probes.
* ``GET /`` β€” root marketing/banner.
* ``GET /docs`` / ``GET /redoc`` / ``GET /openapi.json`` β€” schema viewers.
* ``GET /api/share/...`` β€” public share links by design (Phase 6L).
* ``GET /api/files/...`` / ``GET /files/...`` β€” static export downloads.
"""
from __future__ import annotations
import logging
import time
from collections import deque
from typing import Iterable
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from config import settings
logger = logging.getLogger("nexus.api.security")
# ── Tunables ──────────────────────────────────────────────────────────────
# How many requests one IP can make per hour to a rate-limited endpoint.
# Generous enough for invite-only dogfooding, tight enough to stop abuse.
RATE_LIMIT_PER_HOUR = 20
RATE_LIMIT_WINDOW_SECONDS = 60 * 60
# Endpoints subject to the per-IP rate limit (the expensive ones).
RATE_LIMITED_PATH_PREFIXES: tuple[str, ...] = (
"/api/generate",
"/api/export",
"/api/import",
)
# Endpoints exempt from the X-Nexus-Key check entirely. Health probes
# and public share links never require the shared key.
PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/api/health",
"/api/share",
"/api/files",
"/files",
"/docs",
"/redoc",
"/openapi.json",
# SSE EventSource cannot send custom headers from the browser, so
# /api/status is gated by task_id (UUIDv4) instead of the shared
# key. Subscribing to someone else's task with a known id only
# reveals progress events, never lets you create new work.
"/api/status",
)
PUBLIC_EXACT_PATHS: frozenset[str] = frozenset({"/"})
# ── Helpers ───────────────────────────────────────────────────────────────
def _client_ip(request: Request) -> str:
"""Return the best-effort client IP.
Honours ``X-Forwarded-For`` from the upstream proxy (Fly.io / Cloudflare
both inject it). Falls back to the direct client tuple.
"""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
# First value in the chain is the original client.
return forwarded.split(",")[0].strip()
if request.client is not None:
return request.client.host
return "unknown"
def _is_public_path(path: str) -> bool:
if path in PUBLIC_EXACT_PATHS:
return True
return any(path.startswith(prefix) for prefix in PUBLIC_PATH_PREFIXES)
def _is_rate_limited_path(path: str) -> bool:
return any(path.startswith(prefix) for prefix in RATE_LIMITED_PATH_PREFIXES)
# ── Middleware ────────────────────────────────────────────────────────────
class SecurityMiddleware(BaseHTTPMiddleware):
"""Combined API-key auth + per-IP rate limit.
The middleware is no-op when ``settings.NEXUS_API_KEY`` is empty β€”
that's the local-dev mode. Production sets the key as a Fly secret
and the lock-down activates.
"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
# Per-IP timestamp queues for the sliding window rate limit.
# In-process state: fine for a single-machine free-tier deploy;
# a future phase can swap this for Redis if we scale out.
self._ip_hits: dict[str, deque[float]] = {}
async def dispatch(self, request: Request, call_next):
path = request.url.path
method = request.method
# Preflight requests are handled by CORSMiddleware; let them through.
if method == "OPTIONS":
return await call_next(request)
# The entire lock-down (key gate + rate limit) is one unit that only
# activates when NEXUS_API_KEY is configured. Local dev and CI leave
# it empty, so the middleware is a complete no-op there β€” no key
# check, no rate limit. Production sets the secret and both
# protections turn on together.
if not settings.NEXUS_API_KEY:
return await call_next(request)
# 1) API-key gate (public paths bypass the key check).
if not _is_public_path(path):
supplied = request.headers.get("x-nexus-key", "")
if supplied != settings.NEXUS_API_KEY:
logger.warning(
"security.api_key_rejected",
extra={
"path": path,
"ip": _client_ip(request),
"supplied_len": len(supplied),
},
)
return JSONResponse(
status_code=403,
content={"detail": "forbidden", "reason": "invalid_api_key"},
)
# 2) Per-IP rate limit for expensive endpoints.
if _is_rate_limited_path(path):
ip = _client_ip(request)
now = time.monotonic()
cutoff = now - RATE_LIMIT_WINDOW_SECONDS
hits = self._ip_hits.setdefault(ip, deque())
# Trim old timestamps from the left.
while hits and hits[0] < cutoff:
hits.popleft()
if len(hits) >= RATE_LIMIT_PER_HOUR:
logger.warning(
"security.rate_limited",
extra={"path": path, "ip": ip, "hits": len(hits)},
)
retry_after = int(RATE_LIMIT_WINDOW_SECONDS - (now - hits[0]))
return JSONResponse(
status_code=429,
headers={"retry-after": str(max(retry_after, 1))},
content={
"detail": "rate_limited",
"limit": RATE_LIMIT_PER_HOUR,
"window_seconds": RATE_LIMIT_WINDOW_SECONDS,
},
)
hits.append(now)
return await call_next(request)
__all__ = ["SecurityMiddleware", "RATE_LIMIT_PER_HOUR", "RATE_LIMIT_WINDOW_SECONDS"]