AdminPathsMiddleware — Guard de IP para /admin (core)
Qué es
Middleware Django que protege rutas sensibles (principalmente /admin/ y /api/docs/) limitando acceso únicamente a IPs autorizadas. Parte del sistema de seguridad del núcleo (core), especialmente crítico en entorno multi-inquilino donde el acceso a la administración debe estar acotado al equipo.
Ubicación: core/middleware/admin_paths.py | Clase: AdminPathsMiddleware | Configuración: ALLOWED_NETWORKS, TRUSTED_PROXY_NETWORKS
Responsabilidades
1. Resolver la IP real del cliente
Método: _real_client_ip(request) → ip_address | None
Lee la verdadera IP del cliente resitiendo falsificación de cabeceras:
- Si
REMOTE_ADDRno es un proxy de confianza: usaREMOTE_ADDRdirectamente (infalseabilidad TCP nativa); ignoraX-Forwarded-For. - Si
REMOTE_ADDRestá enTRUSTED_PROXY_NETWORKS(p. ej. Traefik en Docker): recorreX-Forwarded-Forde derecha a izquierda y devuelve el primer hop que no es proxy:- Resiste spoof del valor inicial (p. ej.
127.0.0.1, 37.34.68.35→ devuelve37.34.68.35) - Robusto ante append/replace de XFF por intermediarios
- Resiste spoof del valor inicial (p. ej.
- Si todo falla: devuelve
REMOTE_ADDRcomo fallback (la del proxy mismo).
Pseudo-código:
def _real_client_ip(request):
remote = parse_ip(REMOTE_ADDR)
if not is_trusted_proxy(remote):
return remote # conexión directa → REMOTE_ADDR es autoridad
for hop in reverse(split_xff()):
if not is_trusted_proxy(hop):
return hop # primer no-proxy viendo desde la derecha
return remote # fallback: todos eran proxies
2. Validar si la IP es permitida
Método: _is_allowed_ip(request) → bool
Comprueba si la IP real (obtenida de _real_client_ip) cae dentro de ALLOWED_NETWORKS:
def _is_allowed_ip(request) -> bool:
client_ip = AdminPathsMiddleware._real_client_ip(request)
if client_ip is None:
return False
return any(client_ip in net for net in ALLOWED_NETWORKS)
3. Aplicar restricción en rutas protegidas
Método: __call__(request) (middleware hook)
Intercepta todas las requests. Para cada path en IP_RESTRICTED_PREFIXES:
- Si IP es permitida → deja pasar (
next(request)) - Si no → devuelve
404 Not Found(oculta existencia de ruta, evita révéler que está bajo guard)
Rutas protegidas (IP_RESTRICTED_PREFIXES):
/admin/— Django Admin/api/docs/— Swagger/OpenAPI docs (opcional, pero recomendado en producción)
Configuración
ALLOWED_NETWORKS (IPs de cliente real)
Quién puede acceder a /admin:
ALLOWED_NETWORKS = [
ipaddress.ip_network("37.34.68.35/32"), # Edu — oficina
ipaddress.ip_network("93.176.0.0/16"), # Edu — casa (rango ISP)
ipaddress.ip_network("212.230.190.240/32"), # Edu — nueva sede
ipaddress.ip_network("100.64.0.0/10"), # NetBird VPN (equipo) — acceso directo
ipaddress.ip_network("127.0.0.0/8"), # Localhost (health checks, shell)
]
Notas:
- Solo IPs de cliente real, no de proxies.
- Rango
/10para NetBird = 4M IPs; permite acceso directo a STAGE (sin Traefik). 127.0.0.0/8= shell local + health checks internos.- Si Cloudflare estuviera en el stack, su IP entraría aquí; pero
crearack.comresuelve directo a nuestro servidor (sin Cloudflare).
TRUSTED_PROXY_NETWORKS (proxies de confianza)
Quién puede inyectar/modificar X-Forwarded-For:
TRUSTED_PROXY_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"), # Dokploy network (Traefik → web)
ipaddress.ip_network("172.16.0.0/12"), # Docker compose bridges
ipaddress.ip_network("127.0.0.0/8"), # localhost proxy hops
]
Cómo funciona:
- Si una request llega desde
REMOTE_ADDRen este rango → es del proxy → confía enX-Forwarded-For. - Si llega desde cualquier otra IP → conexión directa →
X-Forwarded-Fores attacker-controlled, se ignora.
Diagrama de flujo
Request a /admin/
|
v
¿REMOTE_ADDR en TRUSTED_PROXY_NETWORKS?
|
+-- NO → IP real = REMOTE_ADDR (directo)
|
+-- SÍ → Camina XFF derecha-a-izquierda, toma primer hop no-proxy
|
v
¿IP real en ALLOWED_NETWORKS?
|
+-- SÍ → 200/302 (ok, next middleware)
|
+-- NO → 404 Not Found
Casos de uso
Acceso legítimo: Edu vía crearack.com (con Traefik)
- Edu abre navegador →
crearack.com/admin - DNS resuelve a
116.203.31.166(servidor nuestro) - Request llega a Traefik (en Docker,
REMOTE_ADDR = 10.0.1.18):REMOTE_ADDR∈TRUSTED_PROXY_NETWORKS✓- Traefik añade
X-Forwarded-For: 37.34.68.35(IP pública de Edu)
- Middleware valida:
- Camina XFF derecha-a-izq. →
37.34.68.35(no es proxy) → devuelve 37.34.68.35∈ALLOWED_NETWORKS✓- Acceso permitido
- Camina XFF derecha-a-izq. →
Acceso legítimo: Edu directo a STAGE (sin Traefik)
- Edu accede a
stage.crearack.local:8000/admin(VPN o red interna) - Request llega directo al app (no hay Traefik):
REMOTE_ADDR = 100.96.156.31(NetBird)REMOTE_ADDR∉TRUSTED_PROXY_NETWORKS→ conexión directa
- Middleware valida:
- IP real =
REMOTE_ADDR = 100.96.156.31 100.96.156.31∈100.64.0.0/10(NetBird) ✓- Acceso permitido
- IP real =
Intento de bypass: Spoof XFF directo
- Atacante envía:
GET /admin HTTP/1.1conX-Forwarded-For: 127.0.0.1directamente a:8000 REMOTE_ADDR = attacker_ip(p. ej.8.8.8.8)- Middleware:
REMOTE_ADDR∉TRUSTED_PROXY_NETWORKS→ conexión directa, XFF ignorado- IP real =
8.8.8.8 8.8.8.8∉ALLOWED_NETWORKS- 404 Not Found — bypass bloqueado ✓
Intento de bypass: Spoof XFF a través de Traefik
- Atacante inyecta:
X-Forwarded-For: 127.0.0.1en request acrearack.com - Traefik recibe, pero Traefik ya conoce la IP real de la conexión TCP → append a XFF:
- Nuevo XFF =
127.0.0.1, attacker_ipoattacker_ip, 127.0.0.1(dependiendo de Traefik)
- Nuevo XFF =
- Middleware:
- Camina XFF derecha-a-izq.: encuentra
attacker_ip(primer no-proxy) attacker_ip∉ALLOWED_NETWORKS- 404 Not Found — bypass bloqueado ✓
- Camina XFF derecha-a-izq.: encuentra
Tests
Archivo: tests/test_admin_paths.py
| Caso | Expected |
|---|---|
Spoof directo (8.8.8.8 + XFF:127.0.0.1) | Blocked ✓ |
| Spoof vía proxy + IP no permitida | Blocked ✓ |
| XFF solo proxy-range | Blocked ✓ |
| Edu vía Traefik | Allowed ✓ |
| Spoof inicial + trailing real IP | Allowed ✓ |
| NetBird directo | Allowed ✓ |
| Edu público directo | Allowed ✓ |
| IP aleatoria directa | Blocked ✓ |
| REMOTE_ADDR malformado | Blocked ✓ |
Ejecutar: python manage.py test tests.test_admin_paths.TestAdminIpGuard -v 2
Notas de seguridad
-
Heurística XFF: No hay certificado de proxy (no verificamos TLS). Dependemos de aislamiento de red (Traefik solo en Docker interno, no accesible desde internet). Ver infra: puerto
:8000bloqueado en firewall Hetzner. -
Localhost en ALLOWED_NETWORKS: Health checks internos (LB Traefik, monitoreo) pueden necesitar acceso local. Se asume que
127.0.0.1no es accesible desde internet (correctamente protegido por firewall). -
RLS: Este middleware es defensa en profundidad para
/admin(Django Admin accesible solo a equipo). No reemplaza RLS en endpoints API ni en modelos de datos. Multi-tenancy se protege en nivel de modelo + filter/viewset. -
Cloudflare: Si algún día se coloca Cloudflare delante, su rango IP tendría que añadirse a
TRUSTED_PROXY_NETWORKS(internamente) y su header real (CF-Connecting-IPoTrue-Client-IP) tendría que leerse específicamente. XFF no es suficiente con Cloudflare.
Véase también
- [[incident—20260602—bypass-admin-xff-spoofing]]
- [[concept—security—multi-tenancy]]
- [[concept—security—authentication]]
- [[entity—core—model—organization]]
- [[runbook—infra—admin-panel-access]]