Seguridad¶
Modelo de amenazas¶
En alcance — lo que Gatekeeper intenta mitigar:
- Fuerza bruta y abuso de API a nivel de IP (rate limiting).
- Once familias de inyección con firma reconocible en query, cabeceras
inspeccionadas y body textual: SQLi, XSS, path traversal, ficheros
sensibles, NoSQL, comandos del sistema, SSRF, SSTI, CRLF, Log4Shell y
proto-pollución de JavaScript. La combinación opcional entre ruta/query y
valores textuales del body requiere
inspect_cross_carrier=True. - Content sniffing y secuestro de framing (cabeceras).
Fuera de alcance — para lo que NO sirve, dicho claro:
- Autenticación, autorización y lógica de negocio.
- Ataques al propio servidor ASGI, a la red o a la base de datos.
- Validación semántica de entradas (eso es de tu app).
- Firma de peticiones, CSRF tokens, gestión de secretos.
- Ataques repartidos entre query y body cuando
inspect_cross_carrier=False, entre query y cabeceras, o dentro de bodies multipart.
El desglose familia a familia, con la frontera de falsos positivos de cada una, está en Modelo de amenazas.
Gatekeeper es una capa más de defensa en profundidad. Tu app sigue necesitando consultas parametrizadas y validación.
Capas de defensa¶
| Capa | Mecanismo |
|---|---|
| Red/proxy | trust_proxy con lectura XFF de derecha a izquierda (trusted_proxies / trusted_proxy_cidrs) y blocked_ips (IP o CIDR) |
| Abuso | Rate limiting por IP, memoria o Redis atómico |
| Transporte | Límite de body (256 KiB → 413 si el tamaño declarado o leído lo supera) y presupuestos de campos y de tiempo de inspección |
| Contenido | Normalización (3 rondas, %uXXXX, NFKC) + reglas acotadas |
Políticas de fallo¶
| Fallo | Política | Efecto |
|---|---|---|
| Redis no responde | rate_fail_open=True (defecto) |
Se admite y se audita rate_backend_error |
| Redis no responde | rate_fail_open=False |
503, disponibilidad sacrificada |
| Configuración inválida | Siempre | ValueError al arrancar, nunca protección silenciosamente ausente |
Body sin Content-Length |
Se lee con límite | 413 solo si el stream supera max_body_size |
La decisión de fail open o fail closed es tuya y está en la configuración, no escondida en el código.
ReDoS: la historia honesta¶
v0.3 midió un backtracking cuadrático real: dos cuantificadores de
blancos adyacentes (\s*[(']?\s*) en las reglas de SQLi hacían que or +
80.000 espacios consumiera 118 s de CPU por petición — un DoS con bodies
de 1 MiB. La respuesta fue:
- acotar todos los cuantificadores de las reglas integradas (
\s{0,16}) → lineal; - test de regresión con entradas patológicas de 1M de caracteres;
- tests de propiedades con hypothesis (
tests/test_fuzz.py) que fallan si alguien reintroduce\s*adyacente o mete una excepción nueva.
v0.4.0 tenía un segundo cuadrático, y no estaba en las reglas. El
normalize() eliminaba los comentarios SQL con
re.sub(r"/\*.*?\*/", " ", text, flags=re.DOTALL): por cada /* sin cierre
el motor recorría el resto del texto. Medido sobre 0.4.0: 23,6 s con
128 KB y 5,5 s con una sola petición de 64 KB, que además devolvía 200 y
bloqueaba el event loop; con el max_body_size de 1 MiB eran ~25 minutos de
CPU por petición. El caso más fácil es un POST con
Content-Type: text/plain y el cuerpo formado por /*a repetido.
Por qué lo pasó por alto el arreglo de v0.3: aquel iba sobre los cuantificadores de las reglas, y este estaba en la normalización, que no es una regla. La lección que queda escrita aquí: "acotar todos los cuantificadores" no es una garantía si el coste puede crecer en otro sitio.
v0.4.1 sustituye los dos re.sub por un barrido lineal con str.find
(_strip_sql_comments()), conservando la semántica: los comentarios
versionados de MySQL (/*!50000union*/) se siguen desenvolviendo porque
MySQL los ejecuta, y un /* sin cierre se deja tal cual. Con el arreglo,
128 KB tardan 0,009 s y el tiempo crece de forma lineal. La regresión
(tests/test_redos_comentarios.py) exige que el coste crezca linealmente,
no solo que «no tarde mucho». Detalle completo y PoC en
SECURITY.md.
Las reglas personalizadas del usuario se validan al arrancar: el riesgo
de backtracking exponencial se rechaza con ValueError y el cuadrático se
avisa en el log. Si necesitas un patrón que no se puede acotar, pon
allow_unsafe_custom_rules=True (queda avisado).
Auditoría sin fugas¶
El logger gatekeeper registra eventos (regla, ruta, IP, tamaño del body)
pero nunca los valores de los payloads: no se vuelca el texto que|matcheó|
la regla, así que un log de Gatekeeper no se puede usar para robar tokens
interceptados. En modo monitor puedes medir falsos positivos sin bloquear y
sin volcar credenciales.
El nombre del campo sí es dato del atacante
El evento incluye field: el nombre del parámetro o el puntero JSON.
Ese nombre lo elige el atacante, así que puede acabar en el log (acotado a
256 caracteres). Los valores no se registran nunca; los nombres sí.
Si vas a mandar estos logs a un tercero, tenlo en cuenta.
Todo el texto que se registra va además saneado: sin ESC ni otros
caracteres de control. Sin eso, un \x1b[2J en la URL podría borrar la
pantalla del operador que abre el log (o reescribir el título de su
ventana).
El puntero JSON se recorta
El campo que aparece en la auditoría es un puntero JSON (/users/0/bio).
Si la ruta es muy profunda, se recorta a 256 caracteres y se marca con
…: se conservan el primer segmento (la clave exterior) y el último (el
campo concreto), pero el tramo intermedio se pierde.
El motivo es un DoS medido: sin recorte, un body de 83 KiB con 14.100 niveles de anidamiento consumía 193,6 MiB por petición. Ver SECURITY_REVIEW.md.
exclude_fields sigue funcionando con punteros recortados: compara por el
último segmento y por el primero.
Versiones soportadas¶
| Versión | Estado |
|---|---|
| 0.4.x | Activa |
| 0.3.x, 0.2.x, 0.1.x | Históricas, sin soporte (0.3.x nunca se publicó en PyPI) |
Reportar una vulnerabilidad¶
Usa GitHub Security Advisories para reportar de forma privada: es la vía que permite corregir y publicar el fix antes de que el fallo sea público. No abras un issue con un fallo explotable. Ver también SECURITY.md en el repo.
Siguiente: Referencia (API) →