Spaces:
Sleeping
IP real detrás del proxy y caché de papers atómica y acotada
Browse filesARCHITECTURE_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 +9 -0
- backend/app/config.py +27 -0
- backend/app/routers/auth.py +4 -2
- backend/app/routers/papers.py +68 -6
- backend/app/security/rate_limit.py +28 -2
- backend/tests/test_cache_papers.py +101 -0
- backend/tests/test_ip_cliente.py +75 -0
|
@@ -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
|
|
@@ -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":
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
return json.loads(archivo.read_text(encoding="utf-8"))
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _escribir_cache(clave: str, datos: dict) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
_DIR_CACHE.mkdir(mode=0o700, parents=True, exist_ok=True)
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:{
|
|
|
|
| 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)}"
|
|
@@ -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
|
|
@@ -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"
|