alexp97 commited on
Commit
7afd4d9
·
1 Parent(s): 22026b0

feat(seguridad): rate limiting en /api + cabeceras de hardening (Fase 10)

Browse files
.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