File size: 7,607 Bytes
9c3ba60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | """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"]
|