CreaRack-SL

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_ADDR no es un proxy de confianza: usa REMOTE_ADDR directamente (infalseabilidad TCP nativa); ignora X-Forwarded-For.
  • Si REMOTE_ADDR está en TRUSTED_PROXY_NETWORKS (p. ej. Traefik en Docker): recorre X-Forwarded-For de 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 → devuelve 37.34.68.35)
    • Robusto ante append/replace de XFF por intermediarios
  • Si todo falla: devuelve REMOTE_ADDR como 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 /10 para 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.com resuelve 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_ADDR en este rango → es del proxy → confía en X-Forwarded-For.
  • Si llega desde cualquier otra IP → conexión directa → X-Forwarded-For es 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)

  1. Edu abre navegador → crearack.com/admin
  2. DNS resuelve a 116.203.31.166 (servidor nuestro)
  3. 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)
  4. Middleware valida:
    • Camina XFF derecha-a-izq. → 37.34.68.35 (no es proxy) → devuelve
    • 37.34.68.35 ∈ ALLOWED_NETWORKS ✓
    • Acceso permitido

Acceso legítimo: Edu directo a STAGE (sin Traefik)

  1. Edu accede a stage.crearack.local:8000/admin (VPN o red interna)
  2. Request llega directo al app (no hay Traefik):
    • REMOTE_ADDR = 100.96.156.31 (NetBird)
    • REMOTE_ADDR ∉ TRUSTED_PROXY_NETWORKS → conexión directa
  3. Middleware valida:
    • IP real = REMOTE_ADDR = 100.96.156.31
    • 100.96.156.31 ∈ 100.64.0.0/10 (NetBird) ✓
    • Acceso permitido

Intento de bypass: Spoof XFF directo

  1. Atacante envía: GET /admin HTTP/1.1 con X-Forwarded-For: 127.0.0.1 directamente a :8000
  2. REMOTE_ADDR = attacker_ip (p. ej. 8.8.8.8)
  3. 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

  1. Atacante inyecta: X-Forwarded-For: 127.0.0.1 en request a crearack.com
  2. Traefik recibe, pero Traefik ya conoce la IP real de la conexión TCP → append a XFF:
    • Nuevo XFF = 127.0.0.1, attacker_ip o attacker_ip, 127.0.0.1 (dependiendo de Traefik)
  3. Middleware:
    • Camina XFF derecha-a-izq.: encuentra attacker_ip (primer no-proxy)
    • attacker_ip ∉ ALLOWED_NETWORKS
    • 404 Not Found — bypass bloqueado ✓

Tests

Archivo: tests/test_admin_paths.py

CasoExpected
Spoof directo (8.8.8.8 + XFF:127.0.0.1)Blocked ✓
Spoof vía proxy + IP no permitidaBlocked ✓
XFF solo proxy-rangeBlocked ✓
Edu vía TraefikAllowed ✓
Spoof inicial + trailing real IPAllowed ✓
NetBird directoAllowed ✓
Edu público directoAllowed ✓
IP aleatoria directaBlocked ✓
REMOTE_ADDR malformadoBlocked ✓

Ejecutar: python manage.py test tests.test_admin_paths.TestAdminIpGuard -v 2

Notas de seguridad

  1. 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 :8000 bloqueado en firewall Hetzner.

  2. Localhost en ALLOWED_NETWORKS: Health checks internos (LB Traefik, monitoreo) pueden necesitar acceso local. Se asume que 127.0.0.1 no es accesible desde internet (correctamente protegido por firewall).

  3. 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.

  4. 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-IP o True-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]]