Skip to main content

Gatekeeper

CI Python mypy License: MIT

In English: README.en.md

Gatekeeper es un middleware ASGI de seguridad para FastAPI y Starlette. Se coloca delante de tu aplicación y, en cada petición, hace tres cosas:

  1. Limita el tráfico por IP (ventana deslizante, en memoria o compartida en Redis).
  2. Añade las cabeceras de seguridad que tu app debería servir y que nadie se acuerda de poner (nosniff, HSTS, X-Frame-Options…).
  3. Busca patrones de ataque en la ruta, la query string, las cabeceras y el body: SQLi, XSS, path traversal, NoSQL, comandos del sistema, SSRF, SSTI, CRLF, Log4Shell y proto-pollución.

Es una capa más de defensa en profundidad, no un WAF: no sustituye a las consultas parametrizadas ni a la validación de entradas. Y no promete "no te van a atacar": publica cuánto detecta y cuánto se equivoca, con tres corpus medidos y con los números a la vista.

Por qué existe

Tres problemas que aparecen en casi cualquier API y que no necesitan a nadie malicioso:

  • Una IP puede pedirlo todo. Sin límite de peticiones, un bucle contra tu login es un ataque de fuerza bruta y un crawler es un DDoS.
  • Las cabeceras de seguridad no se ponen solas. X-Content-Type-Options, Strict-Transport-Security o X-Frame-Options hay que añadirlas a mano en cada servicio, y basta un microservicio olvidado.
  • Un campo sin validar es una puerta. Una búsqueda, un filtro o un parámetro que acaba en una consulta o en una plantilla se convierte en inyección si nadie lo revisa.

Gatekeeper los cubre en un punto, delante de tu app, sin que tengas que tocar endpoint por endpoint.

Instalación

pip install gatekeeper-asgi             # núcleo
pip install "gatekeeper-asgi[yaml]"     # + configuración por YAML
pip install "gatekeeper-asgi[redis]"    # + rate limit compartido entre workers

Requiere Python 3.10+ y Starlette 0.23+ (FastAPI ya la incluye).

Uso

from fastapi import FastAPI
from gatekeeper import Config, GatekeeperMiddleware

app = FastAPI()
app.add_middleware(GatekeeperMiddleware, config=Config(rate_limit=100, rate_window=60))
# o con Redis, para que todos los workers cuenten lo mismo:
# Config(rate_backend="redis", redis_url="redis://localhost:6379/0")
# o: Config.from_yaml("gatekeeper.yaml")   (ver examples/gatekeeper.yaml)

Una línea y tu app ya tiene rate limit, cabeceras de seguridad e inspección de la query y del body. La configuración entera está en la referencia de la API.

Qué pasa cuando detecta algo

Una petición con un payload de SQLi en la query:

$ curl -i "http://localhost:8000/?q=1%27%20OR%20%271%27%3D%271"
HTTP/1.1 403 Forbidden
content-type: application/json
x-content-type-options: nosniff
x-frame-options: DENY
strict-transport-security: max-age=31536000; includeSubDomains

{"detail":"Forbidden"}

Y en el log, un evento JSON (nunca los valores del payload: solo la regla, el nombre del campo que saltó —lo elige el atacante, así que va saneado y acotado— y el patrón):

{"ts": 1793039080.65, "event": "attack_detected", "ip": "203.0.113.7",
 "method": "GET", "path": "/", "rule": "sqli", "source": "query",
 "field": "q", "pattern": "\\b(or|and)\\b\\s{0,16}...", "blocked": true}

El catálogo completo de eventos está en corpus/events.json.

Qué revisa

Detalle
Rate limiting Ventana deslizante por IP. En memoria (un proceso) o en Redis con un script Lua atómico (compartido entre workers), vía redis.asyncio, sin bloquear el event loop
Cabeceras nosniff, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy. Nunca pisa las de tu app, y también van en los rechazos
Detección Once familias: SQLi, XSS, path traversal, ficheros sensibles, NoSQL, comandos del sistema, SSRF, SSTI, CRLF, Log4Shell y proto-pollución. El detalle de cada una, con su frontera de falsos positivos, está en docs/amenazas.md
Superficies Ruta, query string, cabeceras, body JSON y de formulario, nombres de multipart y handshake de WebSocket
Evasiones URL-encoding repetido (3 rondas), escapes IIS %uXXXX, caracteres de ancho completo (NFKC), comentarios SQL /**/ y versionados (/*!50000…*/), entidades HTML numéricas y nombradas
Presupuestos max_body_size (256 KiB), max_fields (1000 textos) y max_inspection_ms (1000 ms). Si se agotan, la petición se rechaza con 413: nunca se admite sin inspeccionar
Reglas propias Regex en YAML o en Config(custom_rules=...), validadas al arrancar (una regex con backtracking catastrófico es un ValueError)
Auditoría Eventos JSON en el logger gatekeeper. Nunca los valores de los campos

Qué mide (no qué promete)

Se miden tres corpus, y los tres tienen guarda en CI: si una regla nueva los empeora, el workflow falla.

El primero es nuestro, escrito a mano y con la procedencia documentada (corpus/):

Métrica (corpus propio) Valor
Ataques detectados 159 / 159 (100.0%)
Falsos negativos sin documentar 0
Peticiones legítimas bloqueadas sin documentar 0 / 131
Falsos positivos documentados (límites aceptados) 8

El segundo es el retenido: un corpus escrito una vez, sin mirar las firmas de las reglas, y desde entonces solo medido. Es el que dice si las reglas generalizan o solo se acuerdan de los casos con los que se escribieron:

Métrica (corpus retenido) Valor
Ataques detectados 103 / 109 (94.5%)
Falsos negativos sin documentar 0 (6 documentados, con su motivo)
Peticiones legítimas bloqueadas sin documentar 0 / 140
Falsos positivos documentados (límites aceptados) 5

Que dé 94,5% y no 100% es lo esperado: el corpus propio se puede usar para ajustar las reglas y el retenido no. Esa diferencia es información, no un defecto: es lo que separa "las reglas detectan esto" de "las reglas detectan lo que se escribieron para ellas". La metodología está en corpus/HELDOUT.md.

El tercero es ajeno: los tests de regresión del OWASP Core Rule Set (Apache-2.0, commit 30cb436, importado con tools/import_crs.py), que escribió otra gente para otro WAF, con sus propios negativos:

Métrica (corpus externo CRS, familias 930/941/942) Valor
Tests importados 1379 (1135 en alcance, 12 excluidos y declarados)
Ataques detectados 465 / 1135 (41.0%)
Negativos de CRS bloqueados (FP) 11 / 232 (4.7%)

Ese 41.0% no es una meta: es la línea base de un corpus diseñado para un WAF de 500+ reglas y payloads mucho más ofuscados, y el workflow la vigila para que no baje. Publicarlo entero es el punto: un 100% propio y un 41.0% ajeno, los dos medidos y los dos con guarda en CI.

El detalle, incluidos los payloads que escapan y por qué, está en corpus/REPORT.md. Estas cifras miden los escaneos individuales; no incluyen inspect_cross_carrier=True.

Configuración

Lo esencial, con el resto en la referencia de la API:

Opción Por defecto Qué hace
rate_limit / rate_window 100 / 60 peticiones permitidas por IP y ventana (0 desactiva)
rate_backend memory memory o redis (compartido entre workers)
inspect_requests / inspect_body True inspección de query y de body
monitor_only False registrar sin bloquear (para medir antes de bloquear)
disabled_rules ∅ apagar familias concretas (sqli, xss, path_traversal…)
rule_packs ∅ (todas) activar un subconjunto: minimal, api, web, infrastructure, strict
exclude_fields [] campos exentos de inspección (parámetro, cabecera o puntero JSON); admite globs
custom_rules ∅ tus propias regex: {nombre: regex} o [{name, pattern}]
max_body_size 256 KiB límite del body inspeccionado
max_fields / max_inspection_ms 1000 / 1000 presupuestos de inspección (se agotan → 413)
redis_timeout 2.0 segundos de espera al conectar con Redis. Con Redis caído, cada petición espera esto (medido: 2,0 s por petición)
allow_paths [] globs exentos de inspección (no de rate limit)
blocked_ips / trust_proxy ∅ / False bloqueo por IP o CIDR, y uso de X-Forwarded-For

Config falla al arrancar, no en producción: claves desconocidas (un typo como rate_limt), regex inválidas o vacías, nombres duplicados, disabled_rules inexistentes y packs desconocidos lanzan ValueError.

Dos decisiones que suelen necesitarse

Si tu app expone menos superficie de la que asumes, en vez de apagar reglas una a una, activa un pack:

from gatekeeper import Config

Config(rule_packs=["api"])   # SQLi, NoSQL, traversal, ficheros, SSRF, Log4Shell, CRLF, cmdi

Un pack desconocido es ValueError al arrancar: una app no puede quedarse sin protección porque alguien escribiera rule_packs=["we"].

Si tu app acepta por diseño un valor que una firma bloquea (un buscador que indexa documentación de seguridad, un parámetro template con Jinja, un webhook), exime ese campo, no la familia entera:

Config(exclude_fields=["template", "bio", "doc_*"])

Funciona en los tres transportes (parámetro de query, cabecera y puntero JSON), y la detección que cruza varios campos se conserva: si excluyes a pero el payload está repartido entre a y b, Gatekeeper quita a del objetivo y sigue vigilando b.

Medir antes de bloquear

Pón el middleware delante con monitor_only=True, deja correr el tráfico real y mira qué salta:

gatekeeper report gatekeeper.log          # ranking de reglas, rutas y campos
gatekeeper report gatekeeper.log --json   # salida legible por máquina

El informe agrupa por (regla, campo) —lo que se repite es ruido— y propone un exclude_fields o un disabled_rules listo para copiar. Acepta el JSON que emite el logger de Gatekeeper o un log de acceso genérico (GET /ruta?q=… 200).

Qué NO cubre

  • La detección por regex tiene falsos positivos y falsos negativos: no sustituye a consultas parametrizadas, a validación de entradas ni a un WAF.
  • El contenido de los ficheros adjuntos no se inspecciona (sí los nombres de campo y de fichero).
  • Los frames de un WebSocket no se inspeccionan: sí el handshake, que además tiene rate limit.
  • Los binarios que no son JSON (imagen, PDF, zip…) no se leen.
  • Por defecto, cada valor del body se analiza por separado. Con inspect_cross_carrier=True se combinan ruta/query con los valores textuales no excluidos; es opt-in y su resultado no está en las cifras de arriba.
  • Los operadores $gt/$lt/$in de Mongo quedan fuera de la firma NoSQL a propósito (los usan los filtros legítimos); localhost y 127.0.0.1 no cuentan como SSRF (OAuth de desarrollo).
  • Con rate_backend="memory" y varios workers, el límite efectivo se multiplica: usa Redis.
  • Si Redis no responde, rate_fail_open=True (por defecto) sigue admitiendo peticiones; ponlo a False si prefieres 503.
  • trust_proxy=False por defecto; actívalo solo detrás de un proxy de confianza, y añade trusted_proxy_cidrs si la app es alcanzable por otro camino.
  • allow_paths exime de inspección, no de rate limit ni de bloqueo de IP.

Demo

Una sesión real contra demo/app.py (uvicorn + curl, sin trucos): el tráfico normal pasa, los payloads reciben 403 y la auditoría registra la regla, el campo y el patrón que saltó — nunca el contenido.

Demo de Gatekeeper

Coste por petición

python benchmarks/latency.py mide el coste en tu máquina, invocando ASGI directamente y sin red. En la última corrida local (Python 3.14.8 en Windows), el escenario completo —inspección de query más rate limit— tuvo un p50 de 141 µs, un p95 de 155 µs y un p99 de 216 µs sobre una base de 13 µs sin middleware. Con un body JSON pequeño, p50 de 244 µs.

El tamaño del body es lo que manda, y conviene decirlo claro:

Body p50 p95 p99 CPU
vacío 141 µs 155 µs 216 µs 0,6 ms
100 KB 8,3 ms 10,9 ms 18,7 ms 9,0 ms

Un body de 100 KB cuesta 59 veces más que uno vacío, y el middleware lo paga antes de que la aplicación vea la petición. El presupuesto por defecto (max_body_size, 256 KiB) es lo que evita que esto crezca sin tope; con bodies grandes, bajarlo es la primera palanca.

Son medidas locales con ruido: no son latencia de extremo a extremo ni un SLA. El benchmark publica p50, p95 y p99 para que la cola no quede escondida detrás de un promedio, y python benchmarks/latency.py --save base.json guarda una línea base para comparar un cambio en la misma máquina. El CI compara el código del PR y el de main en el mismo runner (el ruido de la máquina se cancela; un umbral absoluto no serviría).

Y si Redis se cae, cada petición espera el timeout de conexión antes de decidir: medido, 2,0 s por petición con el valor por defecto (fail-open admitió la petición tras 2.017 ms; fail-closed devolvió 503 tras 2.018 ms). Es un tiempo de espera, no un fallo: la API sigue respondiendo, pero despacio. Se ajusta con redis_timeout (por defecto 2,0 s; bájalo si tu Redis está en la misma red).

Documentación

Guía completa con MkDocs Material en docs/: uso, detección y medición, modelo de amenazas, seguridad, referencia de API y desarrollo. En local: mkdocs serve. Se publica en https://eche-dev.github.io/Gatekeeper/ en cada push a main (build --strict en CI: un enlace roto rompe el workflow).

¿Acabas de llegar? Empieza por la guía de inicio rápido: dos comandos curl verifican que una petición normal da 200 y un ataque da 403. Y si decides dónde desplegarlo, la guía de despliegue dice qué valor cambia en cada topología (un proceso, varios workers, Docker, proxy inverso).

Contribuir: CONTRIBUTING.md · Código de conducta · Plantillas de issues y PR en la raíz del repo.

Desarrollo

pip install -e ".[dev]"
ruff check .            # estilo
mypy                    # tipos estrictos (src/gatekeeper)
bandit -r src -q        # análisis estático de seguridad
pytest --cov=gatekeeper   # incluye fuzzing con hypothesis (tests/test_fuzz.py)
python tools/evaluate.py --check     # tasas de detección y FP (propio + CRS)
python tools/import_crs.py --check   # procedencia del corpus externo
python tools/check_docs.py           # la doc dice la verdad (versión, enlaces, mínimos)
python benchmarks/latency.py         # coste por petición
python -m build && twine check dist/*   # el paquete se puede publicar

Proyecto

Licencia MIT.

Metadata

Release files for gatekeeper-asgi 0.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for gatekeeper-asgi 0.9.0
File Size Uploaded Provenance Actions
gatekeeper_asgi-0.9.0.tar.gz 98.9 kB With provenance Details for gatekeeper_asgi-0.9.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for gatekeeper-asgi 0.9.0
File Interpreter ABI Platform Actions
gatekeeper_asgi-0.9.0-py3-none-any.whl Python 3 none any Details for gatekeeper_asgi-0.9.0-py3-none-any.whl

Total release size: 167.2 kB

Release files / gatekeeper_asgi-0.9.0.tar.gz

Download URL gatekeeper_asgi-0.9.0.tar.gz
Size 98.9 kB
Tags Source
SHA-256 checksum
How to use checksums
a7f81b24fabed1280aad4bdaff9018414a13451dd3778cf3eb87ebb830557668
BLAKE2b-256 checksum
How to use checksums
6cd140f55eacfe6c7f4751c54ff976d8b3b87b62f96caf87c42a125a35166a7d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / gatekeeper_asgi-0.9.0-py3-none-any.whl

Download URL gatekeeper_asgi-0.9.0-py3-none-any.whl
Size 68.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7fc149362c3a4f85906d4d3fc9f866ec37c1b8adda48c7785a4b84e8d900b519
BLAKE2b-256 checksum
How to use checksums
b422cade77235924067b53e1ecf249124c1f8308ca10e7b7d1a567355c5a9d21
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for gatekeeper-asgi 0.9.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for gatekeeper_asgi-0.9.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page