70 lines
2.4 KiB
Python
70 lines
2.4 KiB
Python
"""Edge hardening: run throttling (slowapi), security headers (secure).
|
|
|
|
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
|
|
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
|
|
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from starlette.datastructures import MutableHeaders
|
|
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, 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=[],
|
|
)
|
|
|
|
|
|
def run_limit() -> str:
|
|
return f"{settings.run_throttle_per_minute}/minute"
|
|
|
|
|
|
def reset_throttle() -> None:
|
|
"""Drop all counters (tests)."""
|
|
try:
|
|
limiter._storage.reset() # type: ignore[attr-defined]
|
|
except AttributeError:
|
|
pass
|
|
|
|
|
|
class SecurityHeadersMiddleware:
|
|
"""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":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
async def secured(message: Message) -> None:
|
|
if message["type"] == "http.response.start":
|
|
headers = MutableHeaders(raw=message.setdefault("headers", []))
|
|
for key, value in self.secure_headers.headers.items():
|
|
headers[key] = value
|
|
await send(message)
|
|
|
|
await self.app(scope, receive, secured)
|