Spaces:
Runtime error
Runtime error
feat(seguridad): rate limiting en /api + cabeceras de hardening (Fase 10)
Browse files- .env.example +6 -0
- backend/config.py +2 -0
- backend/core/ratelimit.py +74 -0
- backend/main.py +38 -1
- tests/integration/test_security.py +61 -0
- tests/unit/test_ratelimit.py +63 -0
.env.example
CHANGED
|
@@ -70,6 +70,12 @@ HF_TOKEN=
|
|
| 70 |
# -----------------------------------------------------------------------------------
|
| 71 |
EVENTS_PERSIST=false # true para volcar la telemetría a la tabla events
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
# -----------------------------------------------------------------------------------
|
| 74 |
# 6) Supabase (BD PostGIS + Storage + Queues) — PLATAFORMA COMPLETA
|
| 75 |
# Project Settings -> API / Database. SERVICE_ROLE es SECRETO (solo backend, nunca UI).
|
|
|
|
| 70 |
# -----------------------------------------------------------------------------------
|
| 71 |
EVENTS_PERSIST=false # true para volcar la telemetría a la tabla events
|
| 72 |
|
| 73 |
+
# -----------------------------------------------------------------------------------
|
| 74 |
+
# 7) Seguridad / hardening
|
| 75 |
+
# Rate limiting en memoria sobre /api (anti-abuso/DDoS). 0 = desactivado.
|
| 76 |
+
# -----------------------------------------------------------------------------------
|
| 77 |
+
RATE_LIMIT_PER_MIN=120 # peticiones/min por IP a /api (0 desactiva)
|
| 78 |
+
|
| 79 |
# -----------------------------------------------------------------------------------
|
| 80 |
# 6) Supabase (BD PostGIS + Storage + Queues) — PLATAFORMA COMPLETA
|
| 81 |
# Project Settings -> API / Database. SERVICE_ROLE es SECRETO (solo backend, nunca UI).
|
backend/config.py
CHANGED
|
@@ -73,6 +73,7 @@ class Settings:
|
|
| 73 |
allowed_origins: tuple[str, ...]
|
| 74 |
counting_enabled: bool
|
| 75 |
events_persist: bool
|
|
|
|
| 76 |
max_upload_mb: int
|
| 77 |
confidence_threshold: float
|
| 78 |
|
|
@@ -96,6 +97,7 @@ def get_settings() -> Settings:
|
|
| 96 |
allowed_origins=origins,
|
| 97 |
counting_enabled=_parse_bool(os.getenv("COUNTING_ENABLED"), default=False),
|
| 98 |
events_persist=_parse_bool(os.getenv("EVENTS_PERSIST"), default=False),
|
|
|
|
| 99 |
max_upload_mb=int(os.getenv("MAX_UPLOAD_MB", str(DEFAULT_MAX_UPLOAD_MB))),
|
| 100 |
confidence_threshold=float(
|
| 101 |
os.getenv("CONFIDENCE_THRESHOLD", str(DEFAULT_CONFIDENCE_THRESHOLD))
|
|
|
|
| 73 |
allowed_origins: tuple[str, ...]
|
| 74 |
counting_enabled: bool
|
| 75 |
events_persist: bool
|
| 76 |
+
rate_limit_per_min: int
|
| 77 |
max_upload_mb: int
|
| 78 |
confidence_threshold: float
|
| 79 |
|
|
|
|
| 97 |
allowed_origins=origins,
|
| 98 |
counting_enabled=_parse_bool(os.getenv("COUNTING_ENABLED"), default=False),
|
| 99 |
events_persist=_parse_bool(os.getenv("EVENTS_PERSIST"), default=False),
|
| 100 |
+
rate_limit_per_min=int(os.getenv("RATE_LIMIT_PER_MIN", "120")),
|
| 101 |
max_upload_mb=int(os.getenv("MAX_UPLOAD_MB", str(DEFAULT_MAX_UPLOAD_MB))),
|
| 102 |
confidence_threshold=float(
|
| 103 |
os.getenv("CONFIDENCE_THRESHOLD", str(DEFAULT_CONFIDENCE_THRESHOLD))
|
backend/core/ratelimit.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: ratelimit.py
|
| 3 |
+
Fecha de modificación: 04/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Limitador de tasa **en memoria** (ventana deslizante) usado por el gateway como
|
| 8 |
+
mitigación de abuso y picos de tráfico (defensa en profundidad junto al borde de
|
| 9 |
+
Hugging Face Spaces). Es por proceso: suficiente para una sola instancia (HF free).
|
| 10 |
+
NO sustituye una protección DDoS de red (eso lo aporta el edge del host).
|
| 11 |
+
|
| 12 |
+
Estructura Interna:
|
| 13 |
+
- `SlidingWindowRateLimiter`: cuenta peticiones por clave (IP) en una ventana móvil.
|
| 14 |
+
|
| 15 |
+
Entradas / Dependencias:
|
| 16 |
+
- Biblioteca estándar (`collections.deque`, `time`).
|
| 17 |
+
|
| 18 |
+
Ejemplo de Integración:
|
| 19 |
+
from backend.core.ratelimit import SlidingWindowRateLimiter
|
| 20 |
+
rl = SlidingWindowRateLimiter(max_requests=120, window_seconds=60)
|
| 21 |
+
if not rl.allow(client_ip): ... # responder 429
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import time
|
| 27 |
+
from collections import deque
|
| 28 |
+
from collections.abc import Callable
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class SlidingWindowRateLimiter:
|
| 32 |
+
"""
|
| 33 |
+
Limita peticiones por clave en una ventana de tiempo deslizante.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
max_requests (int): Máximo de peticiones por ventana. `<= 0` desactiva el límite.
|
| 37 |
+
window_seconds (float): Tamaño de la ventana en segundos.
|
| 38 |
+
clock (Callable[[], float]): Fuente de tiempo monótono (inyectable en tests).
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
max_requests: int,
|
| 44 |
+
window_seconds: float = 60.0,
|
| 45 |
+
clock: Callable[[], float] = time.monotonic,
|
| 46 |
+
) -> None:
|
| 47 |
+
self.max_requests = max_requests
|
| 48 |
+
self.window = window_seconds
|
| 49 |
+
self._clock = clock
|
| 50 |
+
self._hits: dict[str, deque[float]] = {}
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def enabled(self) -> bool:
|
| 54 |
+
"""True si el limitador está activo (`max_requests > 0`)."""
|
| 55 |
+
return self.max_requests > 0
|
| 56 |
+
|
| 57 |
+
def allow(self, key: str) -> bool:
|
| 58 |
+
"""
|
| 59 |
+
Registra una petición de `key` y devuelve si está permitida.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
bool: True si está dentro del cupo; False si excede el límite.
|
| 63 |
+
"""
|
| 64 |
+
if not self.enabled:
|
| 65 |
+
return True
|
| 66 |
+
now = self._clock()
|
| 67 |
+
cutoff = now - self.window
|
| 68 |
+
bucket = self._hits.setdefault(key, deque())
|
| 69 |
+
while bucket and bucket[0] <= cutoff:
|
| 70 |
+
bucket.popleft()
|
| 71 |
+
if len(bucket) >= self.max_requests:
|
| 72 |
+
return False
|
| 73 |
+
bucket.append(now)
|
| 74 |
+
return True
|
backend/main.py
CHANGED
|
@@ -37,8 +37,9 @@ from collections.abc import AsyncIterator
|
|
| 37 |
from contextlib import asynccontextmanager
|
| 38 |
from pathlib import Path
|
| 39 |
|
| 40 |
-
from fastapi import FastAPI
|
| 41 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 42 |
from fastapi.staticfiles import StaticFiles
|
| 43 |
|
| 44 |
from backend.api.chat import router as chat_router
|
|
@@ -50,9 +51,26 @@ from backend.api.ndvi import router as ndvi_router
|
|
| 50 |
from backend.api.weather import router as weather_router
|
| 51 |
from backend.config import get_settings
|
| 52 |
from backend.core.inference import ModelNotAvailableError, create_adapter
|
|
|
|
| 53 |
|
| 54 |
_logger = logging.getLogger("agrovision.backend")
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
@asynccontextmanager
|
| 58 |
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
@@ -105,6 +123,25 @@ def create_app() -> FastAPI:
|
|
| 105 |
allow_methods=["*"],
|
| 106 |
allow_headers=["*"], # necesario para las cabeceras BYOK X-User-*
|
| 107 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
app.include_router(fields_router) # Creación de Parcelas
|
| 109 |
app.include_router(ndvi_router) # Teledetección NDVI
|
| 110 |
app.include_router(weather_router) # Clima
|
|
|
|
| 37 |
from contextlib import asynccontextmanager
|
| 38 |
from pathlib import Path
|
| 39 |
|
| 40 |
+
from fastapi import FastAPI, Request
|
| 41 |
from fastapi.middleware.cors import CORSMiddleware
|
| 42 |
+
from fastapi.responses import JSONResponse
|
| 43 |
from fastapi.staticfiles import StaticFiles
|
| 44 |
|
| 45 |
from backend.api.chat import router as chat_router
|
|
|
|
| 51 |
from backend.api.weather import router as weather_router
|
| 52 |
from backend.config import get_settings
|
| 53 |
from backend.core.inference import ModelNotAvailableError, create_adapter
|
| 54 |
+
from backend.core.ratelimit import SlidingWindowRateLimiter
|
| 55 |
|
| 56 |
_logger = logging.getLogger("agrovision.backend")
|
| 57 |
|
| 58 |
+
# Cabeceras de seguridad aplicadas a todas las respuestas (hardening básico).
|
| 59 |
+
_SECURITY_HEADERS = {
|
| 60 |
+
"X-Content-Type-Options": "nosniff",
|
| 61 |
+
"X-Frame-Options": "SAMEORIGIN",
|
| 62 |
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
| 63 |
+
"X-XSS-Protection": "0", # CSP/headers modernos lo sustituyen; evita modos heredados
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _client_key(request: Request) -> str:
|
| 68 |
+
"""IP del cliente para el rate limiting (respeta el proxy del host: X-Forwarded-For)."""
|
| 69 |
+
fwd = request.headers.get("x-forwarded-for")
|
| 70 |
+
if fwd:
|
| 71 |
+
return fwd.split(",")[0].strip()
|
| 72 |
+
return request.client.host if request.client else "unknown"
|
| 73 |
+
|
| 74 |
|
| 75 |
@asynccontextmanager
|
| 76 |
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
|
|
| 123 |
allow_methods=["*"],
|
| 124 |
allow_headers=["*"], # necesario para las cabeceras BYOK X-User-*
|
| 125 |
)
|
| 126 |
+
|
| 127 |
+
# Rate limiting (anti-abuso/DDoS) sobre /api + cabeceras de seguridad en todo.
|
| 128 |
+
# Por proceso/en memoria: defensa en profundidad junto al borde del host (HF Spaces).
|
| 129 |
+
limiter = SlidingWindowRateLimiter(settings.rate_limit_per_min, window_seconds=60.0)
|
| 130 |
+
|
| 131 |
+
@app.middleware("http")
|
| 132 |
+
async def _security_and_ratelimit(request: Request, call_next): # type: ignore[no-untyped-def]
|
| 133 |
+
if limiter.enabled and request.url.path.startswith("/api"):
|
| 134 |
+
if not limiter.allow(_client_key(request)):
|
| 135 |
+
return JSONResponse(
|
| 136 |
+
status_code=429,
|
| 137 |
+
content={"detail": "Demasiadas peticiones. Inténtalo de nuevo en un momento."},
|
| 138 |
+
headers={"Retry-After": "60", **_SECURITY_HEADERS},
|
| 139 |
+
)
|
| 140 |
+
response = await call_next(request)
|
| 141 |
+
for key, value in _SECURITY_HEADERS.items():
|
| 142 |
+
response.headers.setdefault(key, value)
|
| 143 |
+
return response
|
| 144 |
+
|
| 145 |
app.include_router(fields_router) # Creación de Parcelas
|
| 146 |
app.include_router(ndvi_router) # Teledetección NDVI
|
| 147 |
app.include_router(weather_router) # Clima
|
tests/integration/test_security.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: test_security.py
|
| 3 |
+
Fecha de modificación: 04/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Pruebas del *hardening* del gateway: cabeceras de seguridad en las respuestas y el
|
| 8 |
+
rate limiting de `/api` (429 al exceder el cupo). Recrea la app con un límite bajo.
|
| 9 |
+
|
| 10 |
+
Ejecución:
|
| 11 |
+
uv run python -m pytest tests/integration/test_security.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
from fastapi.testclient import TestClient
|
| 18 |
+
|
| 19 |
+
from backend.config import get_settings
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture(autouse=True)
|
| 23 |
+
def _reset(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 24 |
+
monkeypatch.delenv("COUNTING_ENABLED", raising=False)
|
| 25 |
+
get_settings.cache_clear()
|
| 26 |
+
yield
|
| 27 |
+
get_settings.cache_clear()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_cabeceras_de_seguridad_presentes() -> None:
|
| 31 |
+
"""Toda respuesta incluye las cabeceras de hardening."""
|
| 32 |
+
from backend.main import create_app
|
| 33 |
+
|
| 34 |
+
with TestClient(create_app()) as client:
|
| 35 |
+
r = client.get("/api/status")
|
| 36 |
+
assert r.headers.get("X-Content-Type-Options") == "nosniff"
|
| 37 |
+
assert r.headers.get("X-Frame-Options") == "SAMEORIGIN"
|
| 38 |
+
assert "Referrer-Policy" in r.headers
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_rate_limit_devuelve_429(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 42 |
+
"""Al exceder el cupo de /api, el gateway responde 429 con Retry-After."""
|
| 43 |
+
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
| 44 |
+
get_settings.cache_clear()
|
| 45 |
+
from backend.main import create_app
|
| 46 |
+
|
| 47 |
+
with TestClient(create_app()) as client:
|
| 48 |
+
codes = [client.get("/api/status").status_code for _ in range(4)]
|
| 49 |
+
assert codes[:3] == [200, 200, 200]
|
| 50 |
+
assert codes[3] == 429
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_rate_limit_desactivado_no_bloquea(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 54 |
+
"""Con RATE_LIMIT_PER_MIN=0 no se aplica límite."""
|
| 55 |
+
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
| 56 |
+
get_settings.cache_clear()
|
| 57 |
+
from backend.main import create_app
|
| 58 |
+
|
| 59 |
+
with TestClient(create_app()) as client:
|
| 60 |
+
codes = [client.get("/api/status").status_code for _ in range(10)]
|
| 61 |
+
assert all(c == 200 for c in codes)
|
tests/unit/test_ratelimit.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: test_ratelimit.py
|
| 3 |
+
Fecha de modificación: 04/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Pruebas unitarias del limitador de tasa (ventana deslizante en memoria) usado como
|
| 8 |
+
mitigación de abuso/DDoS de la API. Se inyecta un reloj falso para determinismo.
|
| 9 |
+
|
| 10 |
+
Ejecución:
|
| 11 |
+
uv run python -m pytest tests/unit/test_ratelimit.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from backend.core.ratelimit import SlidingWindowRateLimiter
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class _FakeClock:
|
| 20 |
+
"""Reloj controlable para las pruebas (segundos)."""
|
| 21 |
+
|
| 22 |
+
def __init__(self) -> None:
|
| 23 |
+
self.t = 1000.0
|
| 24 |
+
|
| 25 |
+
def __call__(self) -> float:
|
| 26 |
+
return self.t
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_permite_hasta_el_maximo_y_luego_bloquea() -> None:
|
| 30 |
+
"""Admite `max` peticiones en la ventana y rechaza la siguiente."""
|
| 31 |
+
clock = _FakeClock()
|
| 32 |
+
rl = SlidingWindowRateLimiter(max_requests=3, window_seconds=60, clock=clock)
|
| 33 |
+
assert rl.allow("ip-1") is True
|
| 34 |
+
assert rl.allow("ip-1") is True
|
| 35 |
+
assert rl.allow("ip-1") is True
|
| 36 |
+
assert rl.allow("ip-1") is False # 4ª en la misma ventana → bloqueada
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_la_ventana_se_desliza() -> None:
|
| 40 |
+
"""Al avanzar el tiempo más allá de la ventana, vuelve a permitir."""
|
| 41 |
+
clock = _FakeClock()
|
| 42 |
+
rl = SlidingWindowRateLimiter(max_requests=2, window_seconds=60, clock=clock)
|
| 43 |
+
assert rl.allow("ip-1") is True
|
| 44 |
+
assert rl.allow("ip-1") is True
|
| 45 |
+
assert rl.allow("ip-1") is False
|
| 46 |
+
clock.t += 61 # pasa la ventana
|
| 47 |
+
assert rl.allow("ip-1") is True
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_claves_independientes() -> None:
|
| 51 |
+
"""Cada clave (IP) tiene su propio cupo."""
|
| 52 |
+
clock = _FakeClock()
|
| 53 |
+
rl = SlidingWindowRateLimiter(max_requests=1, window_seconds=60, clock=clock)
|
| 54 |
+
assert rl.allow("ip-1") is True
|
| 55 |
+
assert rl.allow("ip-2") is True # otra IP no se ve afectada
|
| 56 |
+
assert rl.allow("ip-1") is False
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_max_cero_desactiva_el_limite() -> None:
|
| 60 |
+
"""`max_requests<=0` desactiva el limitador (siempre permite)."""
|
| 61 |
+
rl = SlidingWindowRateLimiter(max_requests=0, window_seconds=60, clock=_FakeClock())
|
| 62 |
+
for _ in range(1000):
|
| 63 |
+
assert rl.allow("ip-1") is True
|