Jose Salazar Claude Opus 5 commited on
Commit
5118fc3
·
1 Parent(s): 6ab0814

IP real detrás del proxy y caché de papers atómica y acotada

Browse files

ARCHITECTURE_REVIEW §2.6 y §2.5.

PROXY (§2.6). El limitador usaba `request.client.host`, que detrás de un proxy
—HF Spaces tiene uno— es la dirección del PROXY. `limite_login` (5/minute) y
`limite_papers` no eran por IP sino GLOBALES: a la vez un bypass (fuerza bruta
desde muchas IPs contra un único contador) y una auto-denegación de servicio (un
cliente ruidoso agota el login de todos). El throttle por email+IP de
`auth.py` tenía el mismo problema.

`X-Forwarded-For` NO se lee a ciegas: la manda el cliente, y creérsela permite
falsificar la IP en cada petición. El operador declara cuántos proxies hay
delante (`MORPHOS_PROXY_SALTOS_CONFIABLES`, 0 por defecto) y se toma el elemento
-N, que es el último escrito por infraestructura de confianza; lo que hay a la
izquierda lo controla el cliente y se descarta. Aviso al arrancar si en prod
sigue a 0, porque el fallo es silencioso: los límites "funcionan", sólo que
compartidos.

CACHÉ DE PAPERS (§2.5). Tres fallos:
- `write_text` no es atómico. Dos fallos de caché simultáneos sobre la misma
consulta se entrelazaban y un lector hacía `json.loads` de un fichero truncado
→ 500, sin `try` alrededor. Ahora fichero temporal en el mismo directorio y
`os.replace`; el lector ve el contenido viejo o el nuevo, nunca uno a medias.
- El TTL sólo se miraba al LEER, así que una consulta que no se repite dejaba su
fichero para siempre. Se añade poda con tope de entradas.
- El directorio en `gettempdir()` se reutilizaba con `exist_ok=True` aunque lo
hubiera creado otro usuario del host: `mkdir(mode=0o700)` sólo protege si lo
creamos nosotros. Ahora lleva el uid en el nombre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/.env.example CHANGED
@@ -85,6 +85,15 @@ MORPHOS_RAG_MAX_POR_LIBRO=2
85
  # analizador y suele ser correlativo): el cierre real es filtrar por tenant.
86
  MORPHOS_LAB_PENDIENTES_HABILITADO=false
87
 
 
 
 
 
 
 
 
 
 
88
  # --- Rate limiting ---
89
  MORPHOS_LIMITE_INTERPRET=10/minute
90
  MORPHOS_LIMITE_LOGIN=5/minute
 
85
  # analizador y suele ser correlativo): el cierre real es filtrar por tenant.
86
  MORPHOS_LAB_PENDIENTES_HABILITADO=false
87
 
88
+ # --- Proxy inverso ---
89
+ # Saltos de proxy DE CONFIANZA delante de la app. 0 = no confiar en X-Forwarded-For.
90
+ # Detrás de un proxy, `request.client.host` es el PROXY, así que los límites por IP pasan a ser
91
+ # GLOBALES: fuerza bruta desde muchas IPs no se limita, y un cliente ruidoso agota el login de
92
+ # todos. La cabecera no se lee a ciegas porque la manda el cliente: se toma el elemento -N, el
93
+ # último que escribió infraestructura de confianza.
94
+ # En HF Spaces (hay un router delante): 1.
95
+ MORPHOS_PROXY_SALTOS_CONFIABLES=0
96
+
97
  # --- Rate limiting ---
98
  MORPHOS_LIMITE_INTERPRET=10/minute
99
  MORPHOS_LIMITE_LOGIN=5/minute
backend/app/config.py CHANGED
@@ -231,6 +231,24 @@ class Configuracion(BaseSettings):
231
  max_imagenes: int = Field(default=4)
232
  max_bytes_imagen: int = Field(default=6 * 1024 * 1024)
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  # --- Rate limiting ---
235
  limite_interpret: str = Field(default="10/minute")
236
  # Techo por USUARIO además del de IP. La cuota de ZeroGPU es por cuenta y compartida entre
@@ -327,6 +345,15 @@ class Configuracion(BaseSettings):
327
  "registrarse. Si la base de usuarios está vacía, nadie podrá entrar."
328
  )
329
 
 
 
 
 
 
 
 
 
 
330
  def validar_prod(self) -> None:
331
  """Requisitos que sólo aplican en producción; falla cerrado si faltan."""
332
  if self.entorno != "prod":
 
231
  max_imagenes: int = Field(default=4)
232
  max_bytes_imagen: int = Field(default=6 * 1024 * 1024)
233
 
234
+ # --- Proxy inverso ---
235
+ # Saltos de proxy DE CONFIANZA delante de la app. 0 = no confiar en `X-Forwarded-For`.
236
+ #
237
+ # Por qué existe: el limitador usaba `request.client.host`, que detrás de un proxy (HF
238
+ # Spaces, cualquier CDN) es la dirección del PROXY, no la del cliente. Con eso,
239
+ # `limite_login` (5/minute) y `limite_papers` dejaban de ser por IP y pasaban a ser
240
+ # GLOBALES: a la vez un bypass (fuerza bruta desde muchas IPs no se limitaba por IP) y una
241
+ # auto-denegación de servicio (un cliente ruidoso agotaba el login de todos).
242
+ #
243
+ # Se declara el número de saltos en vez de leer la cabecera a ciegas porque `X-Forwarded-For`
244
+ # la pone el cliente: confiar en ella sin más permite falsificar la IP y saltarse cualquier
245
+ # límite poniendo una distinta en cada petición. Cada proxy AÑADE la dirección de su par, así
246
+ # que con N saltos de confianza el cliente real es el elemento -N de la lista; todo lo que
247
+ # haya a la izquierda lo escribió alguien no confiable y se descarta.
248
+ #
249
+ # En HF Spaces detrás de su router: 1.
250
+ proxy_saltos_confiables: int = Field(default=0)
251
+
252
  # --- Rate limiting ---
253
  limite_interpret: str = Field(default="10/minute")
254
  # Techo por USUARIO además del de IP. La cuota de ZeroGPU es por cuenta y compartida entre
 
345
  "registrarse. Si la base de usuarios está vacía, nadie podrá entrar."
346
  )
347
 
348
+ if self.entorno == "prod" and self.proxy_saltos_confiables <= 0:
349
+ # Silencioso y caro: los límites siguen "funcionando", sólo que compartidos por todo
350
+ # el mundo, así que no se nota hasta que alguien agota el login de los demás.
351
+ log.warning(
352
+ "MORPHOS_PROXY_SALTOS_CONFIABLES=0 en producción: si hay un proxy delante "
353
+ "(HF Spaces lo tiene), los límites por IP son en realidad GLOBALES. Declara "
354
+ "cuántos saltos de confianza hay."
355
+ )
356
+
357
  def validar_prod(self) -> None:
358
  """Requisitos que sólo aplican en producción; falla cerrado si faltan."""
359
  if self.entorno != "prod":
backend/app/routers/auth.py CHANGED
@@ -24,7 +24,7 @@ from ..db import (
24
  verificar_password,
25
  )
26
  from ..security.authz import usuario_actual
27
- from ..security.rate_limit import limiter
28
  from ..security.session import (
29
  COOKIE_CSRF,
30
  COOKIE_SESION,
@@ -81,7 +81,9 @@ async def login(request: Request, body: LoginBody, response: Response) -> dict:
81
  # endpoint `async`, así que en el bucle de eventos bloqueaban el proceso entero. scrypt es
82
  # además caro A PROPÓSITO (n=2**14, decenas de ms): es justo el trabajo que no puede vivir
83
  # en el bucle, y el login es el endpoint que más veces lo ejecuta.
84
- ip = request.client.host if request.client else "?"
 
 
85
  if await asyncio.to_thread(intentos_recientes, body.email, ip, _VENTANA_THROTTLE_S) >= _MAX_INTENTOS:
86
  raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Demasiados intentos. Espera unos minutos.")
87
 
 
24
  verificar_password,
25
  )
26
  from ..security.authz import usuario_actual
27
+ from ..security.rate_limit import ip_cliente, limiter
28
  from ..security.session import (
29
  COOKIE_CSRF,
30
  COOKIE_SESION,
 
81
  # endpoint `async`, así que en el bucle de eventos bloqueaban el proceso entero. scrypt es
82
  # además caro A PROPÓSITO (n=2**14, decenas de ms): es justo el trabajo que no puede vivir
83
  # en el bucle, y el login es el endpoint que más veces lo ejecuta.
84
+ # `ip_cliente` y no `request.client.host`: detrás del proxy este último es el proxy, así
85
+ # que el throttle por email+IP degeneraba en un contador global.
86
+ ip = ip_cliente(request)
87
  if await asyncio.to_thread(intentos_recientes, body.email, ip, _VENTANA_THROTTLE_S) >= _MAX_INTENTOS:
88
  raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Demasiados intentos. Espera unos minutos.")
89
 
backend/app/routers/papers.py CHANGED
@@ -8,6 +8,7 @@ from __future__ import annotations
8
 
9
  import hashlib
10
  import json
 
11
  import re
12
  import tempfile
13
  import time
@@ -21,22 +22,83 @@ from ..security.rate_limit import limiter
21
 
22
  router = APIRouter()
23
 
24
- _DIR_CACHE = Path(tempfile.gettempdir()) / "morphos_papers_cache"
 
 
 
25
  _TTL_S = 1800
 
26
  _CABECERAS = {"User-Agent": "Morphos/1.0 (mailto:ceo@equipamed.net)", "Accept": "application/json"}
27
 
28
 
 
 
 
 
29
  def _leer_cache(clave: str) -> dict | None:
30
- archivo = _DIR_CACHE / f"{hashlib.md5(clave.encode()).hexdigest()}.json"
31
- if archivo.exists() and (time.time() - archivo.stat().st_mtime) < _TTL_S:
 
 
 
 
 
 
 
 
 
32
  return json.loads(archivo.read_text(encoding="utf-8"))
33
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  def _escribir_cache(clave: str, datos: dict) -> None:
 
 
 
 
 
 
37
  _DIR_CACHE.mkdir(mode=0o700, parents=True, exist_ok=True)
38
- archivo = _DIR_CACHE / f"{hashlib.md5(clave.encode()).hexdigest()}.json"
39
- archivo.write_text(json.dumps(datos, ensure_ascii=False), encoding="utf-8")
 
 
 
 
 
 
 
 
40
 
41
 
42
  # Sin guarda de sesión a propósito: la búsqueda en PubMed no es sensible ni consume la
 
8
 
9
  import hashlib
10
  import json
11
+ import os
12
  import re
13
  import tempfile
14
  import time
 
22
 
23
  router = APIRouter()
24
 
25
+ # El uid en el nombre evita el caso en que el directorio ya existe creado por OTRO usuario del
26
+ # host compartido: `mkdir(mode=0o700)` sólo protege si lo creamos nosotros, y con `exist_ok=True`
27
+ # un directorio ajeno (o un enlace simbólico plantado ahí) se habría usado tal cual.
28
+ _DIR_CACHE = Path(tempfile.gettempdir()) / f"morphos_papers_cache_{os.getuid()}"
29
  _TTL_S = 1800
30
+ _MAX_ENTRADAS = 500
31
  _CABECERAS = {"User-Agent": "Morphos/1.0 (mailto:ceo@equipamed.net)", "Accept": "application/json"}
32
 
33
 
34
+ def _ruta(clave: str) -> Path:
35
+ return _DIR_CACHE / f"{hashlib.sha256(clave.encode()).hexdigest()}.json"
36
+
37
+
38
  def _leer_cache(clave: str) -> dict | None:
39
+ """Entrada viva de la caché, o None. Un fichero ilegible es un fallo de caché, no un 500.
40
+
41
+ Antes, `json.loads` sobre un fichero a medio escribir tumbaba la petición con 500. Con la
42
+ escritura atómica de abajo eso ya no debería ocurrir, pero la caché vive en un directorio
43
+ temporal que puede truncarse por otras razones (disco lleno, limpieza del host), y un fallo
44
+ de caché siempre es recuperable: se vuelve a pedir a NCBI.
45
+ """
46
+ archivo = _ruta(clave)
47
+ try:
48
+ if (time.time() - archivo.stat().st_mtime) >= _TTL_S:
49
+ return None
50
  return json.loads(archivo.read_text(encoding="utf-8"))
51
+ except FileNotFoundError:
52
+ return None
53
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
54
+ archivo.unlink(missing_ok=True) # corrupta: que no se relea eternamente
55
+ return None
56
+
57
+
58
+ def _podar_cache() -> None:
59
+ """Borra lo caducado y, si aún sobran entradas, lo más antiguo.
60
+
61
+ Sin esto el directorio sólo crecía: el TTL se comprobaba al LEER, así que una consulta que
62
+ no se repite nunca dejaba su fichero para siempre.
63
+ """
64
+ try:
65
+ entradas = list(_DIR_CACHE.glob("*.json"))
66
+ except OSError:
67
+ return
68
+ ahora = time.time()
69
+ vivas = []
70
+ for f in entradas:
71
+ try:
72
+ if ahora - f.stat().st_mtime >= _TTL_S:
73
+ f.unlink(missing_ok=True)
74
+ else:
75
+ vivas.append(f)
76
+ except OSError:
77
+ continue
78
+ if len(vivas) > _MAX_ENTRADAS:
79
+ vivas.sort(key=lambda f: f.stat().st_mtime)
80
+ for f in vivas[: len(vivas) - _MAX_ENTRADAS]:
81
+ f.unlink(missing_ok=True)
82
 
83
 
84
  def _escribir_cache(clave: str, datos: dict) -> None:
85
+ """Escritura ATÓMICA: fichero temporal en el mismo directorio y `os.replace`.
86
+
87
+ `write_text` no es atómico. Dos fallos de caché simultáneos sobre la misma consulta se
88
+ entrelazaban y un lector veía un JSON truncado. `os.replace` dentro del mismo sistema de
89
+ ficheros es atómico: el lector ve el contenido viejo o el nuevo, nunca uno a medias.
90
+ """
91
  _DIR_CACHE.mkdir(mode=0o700, parents=True, exist_ok=True)
92
+ destino = _ruta(clave)
93
+ fd, temporal = tempfile.mkstemp(dir=_DIR_CACHE, suffix=".tmp")
94
+ try:
95
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
96
+ json.dump(datos, fh, ensure_ascii=False)
97
+ os.replace(temporal, destino)
98
+ except OSError:
99
+ Path(temporal).unlink(missing_ok=True) # la caché es opcional: nunca romper la petición
100
+ return
101
+ _podar_cache()
102
 
103
 
104
  # Sin guarda de sesión a propósito: la búsqueda en PubMed no es sensible ni consume la
backend/app/security/rate_limit.py CHANGED
@@ -11,9 +11,35 @@ from fastapi import Request
11
  from slowapi import Limiter
12
  from slowapi.util import get_remote_address
13
 
 
14
  from .session import COOKIE_SESION, leer_sesion
15
 
16
- limiter = Limiter(key_func=get_remote_address)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def clave_usuario(request: Request) -> str:
@@ -30,4 +56,4 @@ def clave_usuario(request: Request) -> str:
30
  sesion = leer_sesion(request.cookies.get(COOKIE_SESION))
31
  if sesion and sesion.get("email"):
32
  return f"user:{sesion['email']}"
33
- return f"ip:{get_remote_address(request)}"
 
11
  from slowapi import Limiter
12
  from slowapi.util import get_remote_address
13
 
14
+ from ..config import obtener_config
15
  from .session import COOKIE_SESION, leer_sesion
16
 
17
+
18
+ def ip_cliente(request: Request) -> str:
19
+ """IP real del cliente, teniendo en cuenta los proxies de confianza declarados.
20
+
21
+ `get_remote_address` devuelve `request.client.host`, que detrás de un proxy es el proxy: en
22
+ HF Spaces eso convertía `limite_login` y `limite_papers` en límites GLOBALES compartidos por
23
+ todo el mundo (bypass de la fuerza bruta y auto-DoS a la vez).
24
+
25
+ `X-Forwarded-For` NO se lee a ciegas —la manda el cliente, y creérsela permite falsificar la
26
+ IP en cada petición y saltarse cualquier límite—. Se usa sólo si el operador declaró cuántos
27
+ proxies hay delante (`proxy_saltos_confiables`), y se toma el elemento -N: cada proxy añade
28
+ la dirección de su par, así que ése es el último valor escrito por infraestructura de
29
+ confianza. Lo de más a la izquierda es lo que dijo el cliente y se descarta.
30
+ """
31
+ saltos = obtener_config().proxy_saltos_confiables
32
+ if saltos > 0:
33
+ reenviada = request.headers.get("x-forwarded-for", "")
34
+ partes = [p.strip() for p in reenviada.split(",") if p.strip()]
35
+ if len(partes) >= saltos:
36
+ return partes[-saltos]
37
+ # Menos entradas de las declaradas: la petición no vino por la cadena esperada. Se cae
38
+ # al peer directo en vez de coger un valor que el cliente controle.
39
+ return get_remote_address(request)
40
+
41
+
42
+ limiter = Limiter(key_func=ip_cliente)
43
 
44
 
45
  def clave_usuario(request: Request) -> str:
 
56
  sesion = leer_sesion(request.cookies.get(COOKIE_SESION))
57
  if sesion and sesion.get("email"):
58
  return f"user:{sesion['email']}"
59
+ return f"ip:{ip_cliente(request)}"
backend/tests/test_cache_papers.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Caché en disco de /api/papers: atómica, tolerante a basura y acotada.
2
+
3
+ Tres fallos que tenía: `write_text` no es atómico (dos fallos de caché simultáneos sobre la
4
+ misma consulta se entrelazaban y un lector veía JSON truncado → 500 sin `try`), el TTL sólo se
5
+ miraba al LEER (una consulta que no se repite dejaba su fichero para siempre) y el directorio
6
+ en `gettempdir()` se reutilizaba aunque lo hubiera creado otro usuario del host.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+
15
+ from app.routers import papers
16
+
17
+
18
+ def _limpiar():
19
+ if papers._DIR_CACHE.exists():
20
+ for f in papers._DIR_CACHE.glob("*"):
21
+ f.unlink(missing_ok=True)
22
+
23
+
24
+ def test_ida_y_vuelta():
25
+ _limpiar()
26
+ papers._escribir_cache("pm:anemia", {"resultados": [1, 2, 3]})
27
+ assert papers._leer_cache("pm:anemia") == {"resultados": [1, 2, 3]}
28
+
29
+
30
+ def test_fichero_truncado_es_fallo_de_cache_no_un_500():
31
+ """El caso real: `json.loads` sobre un fichero a medio escribir tumbaba la petición."""
32
+ _limpiar()
33
+ papers._escribir_cache("pm:x", {"a": 1})
34
+ papers._ruta("pm:x").write_text('{"a": ', encoding="utf-8") # JSON cortado
35
+
36
+ assert papers._leer_cache("pm:x") is None
37
+ # Y se borra: si no, se releería basura hasta que caduque.
38
+ assert not papers._ruta("pm:x").exists()
39
+
40
+
41
+ def test_entrada_caducada_no_se_sirve():
42
+ _limpiar()
43
+ papers._escribir_cache("pm:viejo", {"a": 1})
44
+ viejo = time.time() - papers._TTL_S - 10
45
+ os.utime(papers._ruta("pm:viejo"), (viejo, viejo))
46
+ assert papers._leer_cache("pm:viejo") is None
47
+
48
+
49
+ def test_la_poda_borra_lo_caducado():
50
+ """El TTL se comprobaba sólo al leer, así que el directorio crecía sin tope."""
51
+ _limpiar()
52
+ for i in range(5):
53
+ papers._escribir_cache(f"pm:{i}", {"i": i})
54
+ viejo = time.time() - papers._TTL_S - 10
55
+ for i in range(3):
56
+ os.utime(papers._ruta(f"pm:{i}"), (viejo, viejo))
57
+
58
+ papers._podar_cache()
59
+
60
+ vivas = list(papers._DIR_CACHE.glob("*.json"))
61
+ assert len(vivas) == 2
62
+
63
+
64
+ def test_la_escritura_no_deja_temporales():
65
+ """`os.replace` mueve el temporal; si quedara, el directorio crecería igual."""
66
+ _limpiar()
67
+ papers._escribir_cache("pm:limpio", {"a": 1})
68
+ assert list(papers._DIR_CACHE.glob("*.tmp")) == []
69
+ assert len(list(papers._DIR_CACHE.glob("*.json"))) == 1
70
+
71
+
72
+ def test_un_lector_nunca_ve_un_json_a_medias(monkeypatch):
73
+ """Con escritura atómica, mientras se escribe la nueva entrada se sigue leyendo la vieja.
74
+
75
+ Se simula la ventana de entrelazado: durante `json.dump` del segundo escritor, un lector
76
+ consulta la misma clave. Sin `os.replace` vería el fichero destino truncado.
77
+ """
78
+ _limpiar()
79
+ papers._escribir_cache("pm:carrera", {"version": "vieja"})
80
+ leido_durante_la_escritura = {}
81
+
82
+ volcado_real = json.dump
83
+
84
+ def _volcado_que_lee_a_la_vez(datos, fh, **kw):
85
+ volcado_real(datos, fh, **kw)
86
+ fh.flush()
87
+ leido_durante_la_escritura["valor"] = papers._leer_cache("pm:carrera")
88
+
89
+ monkeypatch.setattr(papers.json, "dump", _volcado_que_lee_a_la_vez)
90
+ papers._escribir_cache("pm:carrera", {"version": "nueva"})
91
+
92
+ assert leido_durante_la_escritura["valor"] == {"version": "vieja"}
93
+ assert papers._leer_cache("pm:carrera") == {"version": "nueva"}
94
+
95
+
96
+ def test_el_directorio_es_privado_y_propio():
97
+ """0700 y con el uid en el nombre: `exist_ok=True` sobre un directorio ajeno lo habría usado."""
98
+ _limpiar()
99
+ papers._escribir_cache("pm:permisos", {"a": 1})
100
+ assert str(os.getuid()) in papers._DIR_CACHE.name
101
+ assert (papers._DIR_CACHE.stat().st_mode & 0o777) == 0o700
backend/tests/test_ip_cliente.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IP de cliente detrás de un proxy inverso.
2
+
3
+ `request.client.host` detrás de un proxy es el PROXY. Con eso, `limite_login` (5/minute) y
4
+ `limite_papers` dejaban de ser por IP y pasaban a ser globales: bypass de la fuerza bruta (muchas
5
+ IPs, un solo contador) y auto-DoS (un cliente ruidoso agota el login de todos) a la vez.
6
+
7
+ La cabecera NO puede creerse a ciegas: la manda el cliente. Estas pruebas fijan las dos mitades
8
+ —se usa cuando el operador declara los saltos, se ignora cuando no— porque equivocarse en
9
+ cualquiera de ellas es un agujero.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pytest
15
+
16
+ from app.config import obtener_config
17
+ from app.security.rate_limit import ip_cliente
18
+
19
+
20
+ class _PeticionFalsa:
21
+ """Lo mínimo que `ip_cliente` mira: cabeceras y peer directo."""
22
+
23
+ def __init__(self, xff: str | None, peer: str = "10.0.0.1"):
24
+ self.headers = {"x-forwarded-for": xff} if xff is not None else {}
25
+ self.client = type("C", (), {"host": peer})()
26
+
27
+
28
+ @pytest.fixture
29
+ def saltos(monkeypatch):
30
+ def _fijar(n: int):
31
+ monkeypatch.setattr(obtener_config(), "proxy_saltos_confiables", n)
32
+
33
+ return _fijar
34
+
35
+
36
+ def test_sin_proxy_declarado_se_ignora_la_cabecera(saltos):
37
+ """El defecto: `X-Forwarded-For` la pone cualquiera, así que sin declarar proxy no vale nada."""
38
+ saltos(0)
39
+ assert ip_cliente(_PeticionFalsa("1.2.3.4", peer="10.0.0.1")) == "10.0.0.1"
40
+
41
+
42
+ def test_un_salto_toma_la_ultima_entrada(saltos):
43
+ """Con un proxy delante, la entrada que él añadió es la del cliente real."""
44
+ saltos(1)
45
+ assert ip_cliente(_PeticionFalsa("203.0.113.9", peer="10.0.0.1")) == "203.0.113.9"
46
+
47
+
48
+ def test_no_se_deja_falsificar_anteponiendo_entradas(saltos):
49
+ """El atacante controla lo que va a la IZQUIERDA; sólo cuenta lo que escribió el proxy.
50
+
51
+ Es el fallo clásico de coger `xff[0]`: bastaría mandar `X-Forwarded-For: <lo-que-sea>` para
52
+ estrenar contador de rate limiting en cada petición.
53
+ """
54
+ saltos(1)
55
+ falsa = _PeticionFalsa("6.6.6.6, 203.0.113.9", peer="10.0.0.1")
56
+ assert ip_cliente(falsa) == "203.0.113.9"
57
+
58
+
59
+ def test_dos_saltos_saltan_el_cdn(saltos):
60
+ """Cliente → CDN → proxy → app: el cliente es el penúltimo."""
61
+ saltos(2)
62
+ peticion = _PeticionFalsa("203.0.113.9, 198.51.100.7", peer="10.0.0.1")
63
+ assert ip_cliente(peticion) == "203.0.113.9"
64
+
65
+
66
+ def test_cadena_mas_corta_de_lo_declarado_cae_al_peer(saltos):
67
+ """La petición no vino por la cadena esperada: mejor el peer directo que un valor del cliente."""
68
+ saltos(2)
69
+ assert ip_cliente(_PeticionFalsa("203.0.113.9", peer="10.0.0.1")) == "10.0.0.1"
70
+ assert ip_cliente(_PeticionFalsa(None, peer="10.0.0.1")) == "10.0.0.1"
71
+
72
+
73
+ def test_tolera_espacios_y_entradas_vacias(saltos):
74
+ saltos(1)
75
+ assert ip_cliente(_PeticionFalsa(" 6.6.6.6 , , 203.0.113.9 ")) == "203.0.113.9"