| from __future__ import annotations |
|
|
| import os |
| import secrets |
| import time |
| import uuid |
| from collections import defaultdict, deque |
| from collections.abc import Awaitable, Callable |
|
|
| from fastapi import Request, status |
| from starlette.middleware.base import BaseHTTPMiddleware |
| from starlette.responses import JSONResponse, Response |
|
|
| from .config import Settings |
|
|
|
|
| PUBLIC_PATHS = { |
| "/", |
| "/health", |
| "/ready", |
| "/version", |
| "/openapi.json", |
| "/studio", |
| } |
|
|
| PUBLIC_PREFIXES = ( |
| "/docs", |
| "/redoc", |
| ) |
|
|
|
|
| def _is_public_path(path: str, settings: Settings) -> bool: |
| if path in PUBLIC_PATHS: |
| return True |
| if path.startswith("/studio/"): |
| return True |
| if settings.allow_public_docs and path.startswith(PUBLIC_PREFIXES): |
| return True |
| return False |
|
|
|
|
| def _has_valid_review_token(path: str) -> bool: |
| prefix = "/automation/reviews/public/" |
| if not path.startswith(prefix): |
| return False |
| token = path[len(prefix):].split("/", 1)[0] |
| if not token: |
| return False |
| from .automation.operations import verify_review_token |
|
|
| return verify_review_token(token) is not None |
|
|
|
|
| class RequestContextMiddleware(BaseHTTPMiddleware): |
| async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: |
| request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex |
| request.state.request_id = request_id |
| start = time.perf_counter() |
| response = await call_next(request) |
| response.headers["X-Request-ID"] = request_id |
| response.headers["X-Response-Time-ms"] = str(round((time.perf_counter() - start) * 1000, 2)) |
| response.headers["X-Content-Type-Options"] = "nosniff" |
| response.headers["X-Frame-Options"] = "DENY" |
| response.headers["Referrer-Policy"] = "no-referrer" |
| response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" |
| return response |
|
|
|
|
| class BodyLimitMiddleware(BaseHTTPMiddleware): |
| def __init__(self, app, settings: Settings) -> None: |
| super().__init__(app) |
| self.settings = settings |
|
|
| async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: |
| content_length = request.headers.get("content-length") |
| if content_length: |
| try: |
| length = int(content_length) |
| except ValueError: |
| return JSONResponse({"detail": "Invalid Content-Length"}, status_code=400) |
| if length > self.settings.request_body_limit_bytes: |
| return JSONResponse({"detail": "Request body too large"}, status_code=413) |
| return await call_next(request) |
|
|
|
|
| class ApiKeyMiddleware(BaseHTTPMiddleware): |
| def __init__(self, app, settings: Settings) -> None: |
| super().__init__(app) |
| self.settings = settings |
|
|
| async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: |
| if _is_public_path(request.url.path, self.settings) or _has_valid_review_token(request.url.path): |
| return await call_next(request) |
| if not self.settings.api_key_required: |
| return await call_next(request) |
| provided = request.headers.get("X-API-Key") or request.query_params.get("api_key") |
| if not self.settings.api_key or not provided or not secrets.compare_digest(provided, self.settings.api_key): |
| return JSONResponse( |
| {"detail": "Invalid or missing API key", "request_id": getattr(request.state, "request_id", None)}, |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| ) |
| return await call_next(request) |
|
|
|
|
| class RateLimitMiddleware(BaseHTTPMiddleware): |
| def __init__(self, app, settings: Settings) -> None: |
| super().__init__(app) |
| self.settings = settings |
| self.hits: dict[str, deque[float]] = defaultdict(deque) |
|
|
| async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: |
| if _is_public_path(request.url.path, self.settings): |
| return await call_next(request) |
| now = time.time() |
| window_start = now - 60 |
| key = request.headers.get("X-API-Key") or request.client.host if request.client else "unknown" |
| bucket = self.hits[key] |
| while bucket and bucket[0] < window_start: |
| bucket.popleft() |
| if len(bucket) >= self.settings.rate_limit_per_minute: |
| return JSONResponse({"detail": "Rate limit exceeded"}, status_code=429) |
| bucket.append(now) |
| return await call_next(request) |
|
|
|
|
| def require_configured_security(settings: Settings) -> None: |
| if settings.api_key_required and not settings.api_key: |
| raise RuntimeError( |
| "MAESTER_API_KEY is required in production. Set MAESTER_ENV=development and MAESTER_ALLOW_DEV_NO_API_KEY=true only for local development." |
| ) |
| render_enabled = os.getenv("MAESTER_ENABLE_RENDER_ENGINE", "true").strip().lower() in {"1", "true", "yes", "on"} |
| if render_enabled and settings.environment == "production": |
| missing = [ |
| name |
| for name in ("AVA2LON_SIGNING_SECRET", "BASYX_SIGNING_SECRET") |
| if not os.getenv(name) |
| ] |
| if missing: |
| raise RuntimeError(f"Missing production signing secret(s): {', '.join(missing)}") |
|
|