Ctrl K
ComunidadGuías…

app/security.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
from __future__ import annotations

import hashlib
import math
import secrets
import threading
import time
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any

from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send

PROTECTED_PATH_PREFIXES = (
    "/v1/",
    "/debug/",
    "/playground/api/",
)
MEDIA_UPLOAD_PATH = "/v1/privacy/files/anonymize"
MULTIPART_OVERHEAD_BYTES = 1024 * 1024


def requires_proxy_auth(path: str) -> bool:
    return any(path.startswith(prefix) for prefix in PROTECTED_PATH_PREFIXES)


def bearer_token_is_allowed(authorization: str | None, allowed_keys: Iterable[str]) -> bool:
    if not authorization:
        return False
    scheme, separator, token = authorization.partition(" ")
    if not separator or scheme.casefold() != "bearer" or not token:
        return False
    allowed = False
    # Compare every configured key so timing does not expose the matching key's
    # position when several tenant credentials are configured.
    for candidate in allowed_keys:
        allowed |= secrets.compare_digest(token, candidate)
    return allowed


def rate_limit_identity(client_host: str | None, authorization: str | None) -> str:
    if authorization:
        scheme, separator, token = authorization.partition(" ")
        canonical_secret = (
            token if separator and scheme.casefold() == "bearer" and token else authorization
        )
        digest = hashlib.sha256(canonical_secret.encode("utf-8")).hexdigest()
        return f"token:{digest}"
    return f"client:{client_host or 'unknown'}"


@dataclass(slots=True)
class _RateWindow:
    started_at: float
    count: int


class FixedWindowRateLimiter:
    """Small in-process guard; deployments should also limit at their edge."""

    def __init__(
        self,
        max_requests: int,
        window_seconds: int,
        *,
        max_identities: int = 10_000,
    ) -> None:
        self.max_requests = max(max_requests, 1)
        self.window_seconds = max(window_seconds, 1)
        self.max_identities = max(max_identities, 1)
        self._windows: dict[str, _RateWindow] = {}
        self._lock = threading.Lock()

    def retry_after(self, identity: str, now: float | None = None) -> int | None:
        current = time.monotonic() if now is None else now
        with self._lock:
            self._remove_expired(current)
            window = self._windows.get(identity)
            if window is None:
                if len(self._windows) >= self.max_identities:
                    return self.window_seconds
                self._windows[identity] = _RateWindow(current, 1)
                return None
            if window.count >= self.max_requests:
                remaining = self.window_seconds - (current - window.started_at)
                return max(math.ceil(remaining), 1)
            window.count += 1
            return None

    def _remove_expired(self, now: float) -> None:
        expired = [
            identity
            for identity, window in self._windows.items()
            if window.started_at + self.window_seconds <= now
        ]
        for identity in expired:
            del self._windows[identity]


class _RequestBodyTooLarge(Exception):
    pass


class RequestBodyLimitMiddleware:
    """Reject oversized protected requests before FastAPI parses their body."""

    def __init__(
        self,
        app: ASGIApp,
        max_bytes: int,
        media_file_max_bytes: int | None = None,
    ) -> None:
        self.app = app
        self.max_bytes = max(max_bytes, 1)
        self.media_body_max_bytes = max(
            (media_file_max_bytes or self.max_bytes) + MULTIPART_OVERHEAD_BYTES,
            self.max_bytes,
        )

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http" or not requires_proxy_auth(scope.get("path", "")):
            await self.app(scope, receive, send)
            return

        body_limit = (
            self.media_body_max_bytes if scope.get("path") == MEDIA_UPLOAD_PATH else self.max_bytes
        )
        headers = dict(scope.get("headers", []))
        content_length = headers.get(b"content-length")
        if content_length is not None:
            try:
                if int(content_length) > body_limit:
                    await self._reject(scope, receive, send)
                    return
            except ValueError:
                await self._reject(scope, receive, send, error_type="invalid_content_length")
                return

        received = 0

        async def limited_receive() -> dict[str, Any]:
            nonlocal received
            message = await receive()
            if message.get("type") == "http.request":
                received += len(message.get("body", b""))
                if received > body_limit:
                    raise _RequestBodyTooLarge
            return message

        try:
            await self.app(scope, limited_receive, send)
        except _RequestBodyTooLarge:
            await self._reject(scope, receive, send)

    @staticmethod
    async def _reject(
        scope: Scope,
        receive: Receive,
        send: Send,
        *,
        error_type: str = "request_too_large",
    ) -> None:
        message = (
            "Request body is too large"
            if error_type == "request_too_large"
            else "Content-Length header is invalid"
        )
        response = JSONResponse(
            status_code=413 if error_type == "request_too_large" else 400,
            content={"error": {"message": message, "type": error_type}},
            headers={"Cache-Control": "no-store"},
        )
        await response(scope, receive, send)