oraculo_api / app /core /middleware.py
Diiegoal's picture
Actualizo la app
011e5b6
Raw
History Blame Contribute Delete
6.42 kB
from __future__ import annotations
import logging
import time
from collections import defaultdict, deque
from threading import Lock
from typing import Deque
from uuid import uuid4
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import Settings
from app.core.error_handlers import build_error_payload
logger = logging.getLogger("oraculo_api.middleware")
def resolve_client_ip(request: Request) -> str:
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("x-request-id", str(uuid4()))
request.state.request_id = request_id
request.state.started_at = time.perf_counter()
request.state.client_ip = resolve_client_ip(request)
response = await call_next(request)
duration_ms = (time.perf_counter() - request.state.started_at) * 1000
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time-MS"] = f"{duration_ms:.2f}"
return response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(self, app, settings: Settings):
super().__init__(app)
self.settings = settings
@staticmethod
def _is_huggingface_space_request(request: Request) -> bool:
host = (request.url.hostname or "").lower()
return host.endswith(".hf.space")
def _frame_ancestors_for_request(self, request: Request) -> str:
if self._is_huggingface_space_request(request):
return "https://huggingface.co https://*.huggingface.co"
return "'none'"
def _content_security_policy_for_request(self, request: Request) -> str:
path = request.url.path
frame_ancestors = self._frame_ancestors_for_request(request)
docs_paths = {
self.settings.docs_url,
self.settings.redoc_url,
self.settings.openapi_url,
}
if path in {value for value in docs_paths if value}:
return (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"img-src 'self' data: https://fastapi.tiangolo.com https://cdn.jsdelivr.net; "
"font-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self'; "
f"frame-ancestors {frame_ancestors}; "
"base-uri 'self';"
)
return f"default-src 'none'; frame-ancestors {frame_ancestors}; base-uri 'none';"
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if not self.settings.security_headers_enabled:
return response
frame_ancestors = self._frame_ancestors_for_request(request)
response.headers["X-Content-Type-Options"] = "nosniff"
if frame_ancestors == "'none'":
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["Content-Security-Policy"] = self._content_security_policy_for_request(request)
return response
class MaxRequestSizeMiddleware(BaseHTTPMiddleware):
def __init__(self, app, max_request_size_bytes: int):
super().__init__(app)
self.max_request_size_bytes = max_request_size_bytes
async def dispatch(self, request: Request, call_next):
content_length = request.headers.get("content-length")
if content_length and int(content_length) > self.max_request_size_bytes:
payload = build_error_payload(
request,
code="payload_too_large",
message="Payload exceeds the maximum allowed size.",
detail={"max_request_size_bytes": self.max_request_size_bytes},
)
return JSONResponse(status_code=413, content=payload)
return await call_next(request)
class SimpleInMemoryRateLimiter:
def __init__(self, max_requests: int, window_seconds: int):
self.max_requests = max_requests
self.window_seconds = window_seconds
self._storage: dict[str, Deque[float]] = defaultdict(deque)
self._lock = Lock()
def is_allowed(self, client_key: str) -> tuple[bool, int]:
now = time.time()
with self._lock:
bucket = self._storage[client_key]
while bucket and now - bucket[0] > self.window_seconds:
bucket.popleft()
if len(bucket) >= self.max_requests:
retry_after = max(1, int(self.window_seconds - (now - bucket[0])))
return False, retry_after
bucket.append(now)
return True, 0
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, settings: Settings):
super().__init__(app)
self.settings = settings
self.limiter = SimpleInMemoryRateLimiter(
max_requests=settings.rate_limit_requests,
window_seconds=settings.rate_limit_window_seconds,
)
async def dispatch(self, request: Request, call_next):
if not self.settings.rate_limit_enabled or request.url.path in self.settings.rate_limit_exempt_paths:
return await call_next(request)
client_key = resolve_client_ip(request)
is_allowed, retry_after = self.limiter.is_allowed(client_key)
if not is_allowed:
payload = build_error_payload(
request,
code="rate_limit_exceeded",
message="Rate limit exceeded. Please retry later.",
detail={"retry_after_seconds": retry_after},
)
return JSONResponse(
status_code=429,
content=payload,
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)