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"]