Gatekeeper
Middleware ASGI de seguridad para FastAPI/Starlette. Una capa extra delante de tu app: rate limiting, cabeceras seguras, detección de patrones de ataque en query y body, reglas propias por YAML y auditoría en JSON.
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
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)
Qué cubre
| Protección | Detalle |
|---|---|
| Rate limiting | Ventana deslizante por IP. En memoria (un proceso) o en Redis con 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 (no pisa las de tu app) |
| SQLi | UNION SELECT, tautologías (' OR '1'='1, ?a=1=1), consultas apiladas (; DROP), time-based (sleep(), WAITFOR DELAY), error-based (EXTRACTVALUE, CONVERT), sondeo de columnas (ORDER BY 10) |
| XSS | <script>, handlers de evento con y sin etiqueta (onerror=, onload=), javascript:, data:text/html, con doble codificación |
| Path traversal | ../, ..\, ..;/, con decodificación repetida y frontera de segmento (foo../x no es un salto) |
| Ficheros sensibles | /etc/passwd, shadow, subuid; /proc/self/environ; win.ini; .ssh/id_*; .aws/credentials; .docker/secrets; .git/config; .env (exigen separador: ?q=.env pasa) |
| NoSQL | Operadores de inyección Mongo: $ne, $nin, $where, $regex en JSON o corchetes (user[$ne]=x). $gt/$lt/$in quedan fuera: los usan los filtros legítimos |
| Comandos del sistema | Separador shell + binario (; ls, | cat, && whoami, $(id), `uname`), LOLBins de Windows (powershell.exe), webshells PHP (;system(', shell_exec(), reverse shell /dev/tcp/ |
| SSRF | Metadata cloud (AWS/GCP/Azure/Alibaba) y esquemas de lectura local (file://, gopher://, dict://, jar://); localhost no bloquea (OAuth de desarrollo) |
| SSTI | Aritmética de plantillas ({{7*7}}, ${7*7}, <%= 7*7 %>, #{7*7}) y objetos peligrosos (config, self, __class__, __subclasses__) |
| CRLF | Salto de línea seguido de cabecera: response splitting e inyección de cabeceras (CRLF real o \r\n literal) |
| Log4Shell | ${jndi:ldap://…} con sus ofuscaciones (${${::-j}ndi:) y lookups de exfiltración (${env:AWS_SECRET…}) |
| Proto-pollución | __proto__ en cualquier posición y constructor.prototype |
| Evasiones | URL-encoding repetido (3 rondas), escapes IIS %uXXXX mayúsculas o no, ancho completo (<script vía NFKC), comentarios SQL /**/ y versionados (/*!50000union*/), entidades HTML numéricas y nombradas (<script), tab/NL dentro del esquema (java\tscript:) |
| Límite de body | max_body_size (256 KiB por defecto) → 413 si el tamaño declarado o leído lo supera |
| Presupuestos | max_fields (1000 textos) y max_inspection_ms (1000 ms) → 413 si se agotan: el coste de inspeccionar escala con los campos, no con los bytes |
| Content-type genérico | application/octet-stream se sniffea: si el cuerpo es JSON, se inspecciona como JSON (la cabecera la controla el atacante) |
| Multipart | Se inspeccionan los nombres de campo y de fichero (filename=../../etc/passwd), no el contenido de los ficheros. inspect_multipart=False lo desactiva |
| WebSocket | El handshake (ruta, query y cabeceras) se inspecciona y tiene rate limit; los frames no. inspect_websockets=False lo desactiva |
| Cruce query + body | Opt-in con inspect_cross_carrier=True; combina ruta/query con valores textuales no excluidos del body, puede añadir falsos positivos, probar primero con monitor_only=True |
| Reglas propias | Regex en YAML o Config(custom_rules=...), validadas al arrancar |
| Exclusión de rutas | allow_paths (globs). Rutas con .., \ o codificación residual nunca se eximen |
| Auditoría | Eventos JSON en el logger gatekeeper; nunca los valores de los campos (sí el nombre del que saltó, saneado y acotado) |
| Modo monitor | monitor_only=True registra sin bloquear, para medir falsos positivos en tu tráfico real |
Qué mide (no qué promete)
Se miden tres corpus. El primero es nuestro, redactado y con 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 |
| Falsos negativos documentados (límites aceptados) | 0 |
Once familias de detección: SQLi, XSS, path traversal, ficheros sensibles, NoSQL, comandos del sistema, SSRF, SSTI, CRLF, Log4Shell y proto-pollución. El desglose con la frontera de falsos positivos de cada una está en docs/amenazas.md.
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. La metodología, y los fallos que este corpus encontró en las reglas, están en corpus/HELDOUT.md.
El tercero es ajeno: los tests de regresión de
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.
Estas cifras miden los escaneos individuales; no incluyen
inspect_cross_carrier=True.
El detalle, incluidos los payloads que se escapan y por qué, está en
corpus/REPORT.md. El workflow falla si una regla nueva
empeora esas cifras (python tools/evaluate.py --check).
Demo
Una sesión real contra demo/app.py (uvicorn + curl, sin trucos):
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.
La app de la demo incluye un playground con botones y configs de despliegue
para Vercel (vercel --prod) y Docker: demo/.
Latencia
python benchmarks/latency.py mide el coste por petición en tu máquina,
invocando ASGI directamente y sin red:
| Escenario | p50 | p95 | p99 | +p50 vs base |
|---|---|---|---|---|
| sin middleware (base) | 17.40 | 56.70 | 308.70 | — |
| solo cabeceras | 41.60 | 194.10 | 989.50 | +23.50 |
| inspección de query, sin body ni rate limit | 206.10 | 937.80 | 2057.20 | +171.20 |
| inspección de body JSON, sin rate limit | 346.60 | 612.90 | 1029.60 | +329.20 |
| cruce query/body opt-in (benigno) | 434.80 | 715.00 | 1179.90 | +429.20 |
| cruce query/body opt-in (ataque, 403) | 322.90 | 474.50 | 709.50 | +305.50 |
| rate limit solo | 46.50 | 55.30 | 99.00 | +27.30 |
| completo (inspección + rate limit) | 193.40 | 394.10 | 677.90 | +173.70 |
| request atacante (403 temprano) | 114.40 | 331.30 | 743.20 | +97.40 |
Una corrida local de Python 3.14.8 en Windows, con 1000 iteraciones × 5 rondas, produjo esos percentiles; la columna de overhead usa 3 repeticiones. Los resultados dependen del entorno y no son una promesa de latencia de producción. Los escenarios de body y cruce entregan el body real al middleware; los tests verifican que el caso benigno responde 200 y el cruce de ataque 403.
El pre filtro de familias. Antes de ejecutar las regex, comprueba si el
texto contiene un literal obligatorio de cada familia y omite las que no
pueden coincidir. Para textos de menos de 128 caracteres no lo aplica. Esta
optimización no debe cambiar detecciones. tests/test_prefilter.py compara el
escaneo con y sin pre filtro sobre los 1787 valores de ambos corpus (payloads
propios, solicitudes CRS y valores de cabecera) y exige el mismo resultado.
El benchmark publica p50, p95 y p99 por petición para que la cola no quede oculta por un promedio. En esta corrida el escenario completo tuvo p50 de 193.40 µs, p95 de 394.10 µs y p99 de 677.90 µs; son medidas locales con ruido, no latencia de extremo a extremo ni un SLA.
python benchmarks/latency.py --save base.json y, tras cambiar el código,
python benchmarks/latency.py --baseline base.json comparan dos versiones en
tu máquina y dicen si algún escenario empeoró más de un margen (25 % por
defecto). Sirven para comparar cambios bajo condiciones locales similares
antes de abrir un PR que toca el camino caliente.
Comparar rendimiento
No hay un umbral fijo de latencia en CI: los runners compartidos no son una base estable. El benchmark es un informe local; para comparar cambios, guarda una línea base y ejecuta ambas versiones en la misma máquina:
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.
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 (las subidas pasan intactas; sí se inspeccionan los nombres de campo y de fichero).
- Los frames de un WebSocket no se inspeccionan: sí el handshake (ruta, query y cabeceras), 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, los valores textuales no excluidos se combinan con ruta/query; las claves JSON y las cabeceras no participan. inspect_cross_carrier=Truecombina ruta/query con los valores textuales no excluidos del body cuando los escaneos individuales no detectan nada. Las claves se inspeccionan individualmente, pero no se combinan. Es opt-in; su resultado no está incluido en las métricas publicadas del corpus.- Los operadores
$gt/$lt/$inde Mongo quedan fuera de la firma NoSQL a propósito (los usan los filtros legítimos);localhosty127.0.0.1no 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 aFalsesi prefieres 503. trust_proxy=Falsepor defecto; actívalo solo detrás de un proxy de confianza. Contrust_proxy=Trueel XFF se lee de derecha a izquierda (ips[-trusted_proxies]); añadetrusted_proxy_cidrssi la app es alcanzable por otro camino.- Un body JSON/form mayor que
max_body_sizese rechaza: súbelo o usainspect_body=False. allow_pathsexime de inspección, no de rate limit ni de bloqueo de IP.
Configuración
| Opción | Por defecto | Descripción |
|---|---|---|
rate_limit / rate_window |
100 / 60 |
peticiones permitidas por IP y ventana (0 desactiva) |
rate_backend |
memory |
memory o redis |
redis_url / redis_prefix |
redis://localhost:6379/0 / gatekeeper:rl: |
conexión y prefijo de claves |
rate_fail_open |
True |
política si Redis no responde: admitir o 503 |
inspect_requests / inspect_body |
True |
inspección de query y de body |
inspect_cross_carrier |
False |
combinar ruta/query + valores textuales del body tras escaneos individuales limpios |
monitor_only |
False |
registrar sin bloquear |
disabled_rules |
∅ |
sqli, xss, path_traversal, sensitive_files, nosql, cmdi, ssrf, ssti, crlf, log4shell, proto_pollution o tus reglas |
rule_packs |
∅ (todas) |
packs opt-in: minimal, api, web, infrastructure, strict (ver abajo) |
exclude_fields |
[] |
campos (parámetro, cabecera o puntero JSON) exentos de inspección; admite globs |
custom_rules |
∅ |
{nombre: regex} o lista {name, pattern} |
max_body_size |
256 KiB |
límite del body inspeccionado |
max_fields |
1000 |
textos inspeccionados por petición (clave + valor = 2); 0 = sin límite |
max_inspection_ms |
1000 |
milisegundos de inspección por petición; 0 = sin límite |
allow_paths |
[] |
globs exentos de inspección |
inspect_headers |
user-agent, referer, cookie, x-forwarded-for |
cabeceras inspeccionadas (∅ desactiva) |
blocked_ips / trust_proxy |
∅ / False |
bloqueo por IP o CIDR y uso de X-Forwarded-For |
trusted_proxies / trusted_proxy_cidrs |
1 / ∅ |
hops de proxy a saltar en el XFF y redes de las que se cree |
security_headers |
ver fuente | None = cabeceras por defecto |
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.
Las reglas propias se evalúan sobre texto decodificado, sin comentarios /**/ y
en minúsculas; evita patrones con backtracking catastrófico, los patrones
propios no están acotados por Gatekeeper.
Packs de reglas
Las once familias se evalúan por defecto. Si tu app expone menos superficie de
la que asumes, puedes activar un subconjunto (los packs son opt-in: sin
rule_packs no cambia nada respecto a lo que ya hacía Gatekeeper).
| Pack | Familias | Para qué |
|---|---|---|
strict |
las once | defecto explícito; apps que sirven HTML de terceros |
web |
+ xss sobre api |
web pública con HTML (el pack que casi todos quieren) |
api |
sqli, nosql, path_traversal, sensitive_files, ssrf, log4shell, crlf, cmdi |
APIs JSON/GraphQL: sin xss, ssti ni proto_pollution porque no hay plantilla que renderizar |
minimal |
sqli, path_traversal |
solo lo que no depende de la superficie expuesta |
infrastructure |
ssrf, log4shell, crlf, cmdi |
segunda capa tras un WAF, o backend sin SQL |
from gatekeeper import Config
Config(rule_packs=["api"], disabled_rules=["cmdi"]) # API sin XSS ni SSTI, sin cmdi
Un pack desconocido es ValueError al arrancar: una app no puede quedarse sin
protección porque alguien escribiera rule_packs=["we"].
Excluir campos (y no campos)
Cuando 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), exímeno ese campo, no la familia entera:
Config(exclude_fields=["template", "bio", "doc_*"])
Funciona en los tres transportes: parámetro de query, cabecera (user-agent)
y puntero JSON (/user/bio o solo bio). 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. La alternativa es
disabled_rules, que apaga la familia para toda la app.
gatekeeper report: mide antes de bloquear
Poner el middleware delante en monitor_only=True, dejar correr el tráfico real
y mirar qué salta:
monitor_only: true # en tu configuración, mientras tanto
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). Los eventos de auditoría no incluyen el contenido del
payload, así que este informe tampoco puede filtrar lo que el log no trae.
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 benchmarks/latency.py # coste por petición
python -m build && twine check dist/* # el paquete se puede publicar
Proyecto
- CHANGELOG.md — qué cambia en cada versión.
- SECURITY.md — modelo de amenazas, límites y cómo reportar fallos.
- corpus/REPORT.md — informe de detección generado.
Licencia MIT.
Metadata
Release files for gatekeeper-asgi 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| gatekeeper_asgi-0.7.0.tar.gz | 493.5 kB | With provenance | Details for gatekeeper_asgi-0.7.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| gatekeeper_asgi-0.7.0-py3-none-any.whl | Python 3 | none | any | Details for gatekeeper_asgi-0.7.0-py3-none-any.whl |
Total release size: 561.4 kB
Release files / gatekeeper_asgi-0.7.0.tar.gz
| Download URL | gatekeeper_asgi-0.7.0.tar.gz |
|---|---|
| Size | 493.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3454b9b319b408f97a256b3c39928bdc4f985b8659304ecfaa3b40e0af6758e2
|
|
BLAKE2b-256 checksum How to use checksums |
db8fa62455e93ea20fc37824561029d81265ddb8a6a7334d324adac9910220e4
|
| 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 26, 2026.
Transparency logRelease files / gatekeeper_asgi-0.7.0-py3-none-any.whl
| Download URL | gatekeeper_asgi-0.7.0-py3-none-any.whl |
|---|---|
| Size | 67.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
478c7c5c188995cc6dd6cca428d73296c1958ccee9de9158efc2702f51609647
|
|
BLAKE2b-256 checksum How to use checksums |
4e67f793cb444cb8509370a51d131a7af7a1d118db96095aba86ce0a97a87267
|
| 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 26, 2026.
Transparency logSecurity for gatekeeper-asgi 0.7.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.
Attestation for gatekeeper_asgi-0.7.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 26, 2026.
Transparency logA 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.