refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter
This commit is contained in:
parent
06ab25fb47
commit
03af41e837
74 changed files with 6089 additions and 5183 deletions
|
|
@ -1,79 +1,59 @@
|
|||
"""Edge hardening: run throttling, security headers.
|
||||
"""Edge hardening: run throttling (slowapi), security headers (secure).
|
||||
|
||||
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
|
||||
main.py). All limits are settings-driven so tests can shrink them. State
|
||||
is in-process memory: correct for the single-replica SQLite deployment;
|
||||
revisit with Redis if the API ever scales horizontally.
|
||||
main.py). The run throttle is settings-driven so tests can shrink it.
|
||||
State is in-process memory: correct for the single-replica SQLite
|
||||
deployment; revisit with Redis if the API ever scales horizontally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
|
||||
# per client IP and minute.
|
||||
RUN_PATH_SUFFIX = "/run"
|
||||
THROTTLE_WINDOW_SECONDS = 60.0
|
||||
# per client IP and minute, limit read live from settings so tests can
|
||||
# shrink it without re-creating the app.
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri="memory://",
|
||||
default_limits=[],
|
||||
)
|
||||
|
||||
_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def run_limit() -> str:
|
||||
return f"{settings.run_throttle_per_minute}/minute"
|
||||
|
||||
|
||||
def reset_throttle() -> None:
|
||||
"""Drop all counters (tests)."""
|
||||
_hits.clear()
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
|
||||
# scope["client"] from X-Forwarded-For.
|
||||
if request.client is not None:
|
||||
return request.client.host
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
return forwarded.split(",")[0].strip() or "unknown"
|
||||
|
||||
|
||||
class RunThrottleMiddleware:
|
||||
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if (
|
||||
scope["type"] != "http"
|
||||
or scope.get("method") != "POST"
|
||||
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
request = Request(scope)
|
||||
now = time.monotonic()
|
||||
window_start = now - THROTTLE_WINDOW_SECONDS
|
||||
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
|
||||
if len(bucket) >= settings.run_throttle_per_minute:
|
||||
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
|
||||
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
|
||||
response.headers["Retry-After"] = str(retry_after)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
bucket.append(now)
|
||||
_hits[_client_ip(request)] = bucket
|
||||
await self.app(scope, receive, send)
|
||||
try:
|
||||
limiter._storage.reset() # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Baseline response headers (TLS/HSTS live on the proxy)."""
|
||||
"""Baseline response headers (TLS/HSTS live on the proxy).
|
||||
|
||||
Built with the ``secure`` package; values stay byte-identical to the
|
||||
previous hand-rolled version (see test_middleware).
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
import secure as secure_lib
|
||||
|
||||
self.app = app
|
||||
self.secure_headers = secure_lib.Secure(
|
||||
xcto=secure_lib.XContentTypeOptions().nosniff(),
|
||||
xfo=secure_lib.XFrameOptions().sameorigin(),
|
||||
referrer=secure_lib.ReferrerPolicy().strict_origin_when_cross_origin(),
|
||||
csp=secure_lib.ContentSecurityPolicy().frame_ancestors("'self'"),
|
||||
)
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
|
|
@ -83,10 +63,8 @@ class SecurityHeadersMiddleware:
|
|||
async def secured(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(raw=message.setdefault("headers", []))
|
||||
headers["X-Content-Type-Options"] = "nosniff"
|
||||
headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||||
for key, value in self.secure_headers.headers.items():
|
||||
headers[key] = value
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, secured)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue