CreaRack-SL

Auditoría Suprema 2 · Tanda 9: SSRF por IPv6 mapeada, XSS almacenado en stencils y borde anónimo de signage

Cuándo

01-09-2026 · commit 024052ce (PR #488, v1.100.0). Novena tanda de la Auditoría Suprema 2: no nace de un dominio nuevo del plan de arreglos, sino del triage de la cola de hallazgos (sesión 298), que destapó 2 ALTA que ningún agrupamiento temático de las Tandas 1-8 había recogido — quedaron huérfanos entre “core”, “racks” y “monitoring” hasta que alguien revisó la cola entera.

Síntomas visibles

Cinco hallazgos distintos en el mismo commit:

  1. SSRF por IPv6 mapeada (monitoring/services/net_guard.py): las direcciones ::ffff:127.0.0.1 y ::ffff:169.254.169.254 — la forma IPv6 “mapeada” de un loopback o de la IP de metadata de la nube — conectan exactamente al mismo destino que sus equivalentes IPv4, que sí estaban bloqueados. Pero _ip_blocked comparaba el objeto ipaddress contra una lista de redes por versión, y una IPv6 nunca cae dentro de una red IPv4: el filtro las dejaba pasar sin más.
  2. XSS almacenado por la librería de stencils (racks/api/library.py, core/views.py): create_from_image y create_custom_stencil guardaban cualquier fichero subido sin exigir ningún permiso (un usuario de solo lectura podía subir), sin validar el tipo ni sanear el contenido, y el resultado se servía same-origin por /media/. Un .svg o .html con <script> se ejecutaba en el origen de la app en cuanto alguien navegaba a esa URL.
  3. Borde anónimo del portal de signage (signage/views.py): el PIN de un proyecto de cliente solo protegía la vista de lectura (client_portal_view); los tres POST de acción (client_upload, client_create_playlist, client_publish) aceptaban solo el token —que viaja en la URL y por tanto en proxies, historial y cabecera Referer— sin comprobar el PIN. client_publish, además, no exigía el permiso schedule que sí calculaba la vista de lectura.
  4. Gates de core incompletos: /api/search enumeraba racks, equipos y blueprints sin comprobar el scope de lectura de quien preguntaba; la suspensión de una organización se comprobaba DESPUÉS de la lista de exenciones de ModuleGatingMiddleware (que incluye /api/users, /api/credentials, /api/export, /api/admin, /api/agent), así que un tenant suspendido conservaba gestión de usuarios, credenciales y export; /api/help/article servía cualquier ruta del workspace validando solo .., filtrando por whitelist únicamente en el LISTADO, no en la lectura directa — un autenticado podía leer doc técnica/interna (ADRs, concept--/decision--/incident--) vía el proxy de ayuda; y help_ask/help_ask_stream (Gemma de pago) no tenían ningún tope de coste por organización.
  5. Dos XSS de frontend menores (import Visio, walk SNMP) sin escapar en el editor.

Causa raíz

Ningún patrón único explica las cinco — es la cola de una auditoría grande, con hallazgos de forma distinta:

  • Filtro de red que compara por tipo, no por destino real: _ip_blocked trataba “es una IPv6” y “es una IPv4 bloqueada” como categorías separadas, cuando ambas representaciones pueden apuntar al mismo host. El bug clásico de comparar la forma sintáctica del dato en vez de su valor semántico.
  • Mutadores sin permiso por omisión: los 4 endpoints de stencils nacieron sin require_perm, asumiendo implícitamente que solo un usuario válido llegaría a ellos — sin exigir explícitamente el rol, cualquier autenticado (incluido de solo lectura) quedaba habilitado.
  • Guardas que protegen la puerta de lectura pero no las de escritura: el PIN de signage se implementó en la vista GET y se dio por hecho que cubría todo el flujo; los POST se añadieron después sin heredar la misma comprobación.
  • Orden de comprobación en middleware: ModuleGatingMiddleware calculaba la exención de módulo antes que la suspensión de organización, así que “este path no necesita módulo activo” (pensado para utilidades) terminaba significando también “este path no necesita organización activa” — dos preguntas distintas resueltas con el mismo if.

Fix aplicado

Commit 024052ce7db543e6ff35eb6b1a3e7659061a1c2e (PR #488):

  • net_guard._ip_blocked calcula también la forma ipv4_mapped de la IP candidata (cuando existe) y la compara contra la lista de redes bloqueadas junto con la original — ambas presentaciones quedan cubiertas. Añadido además ::/128 (IPv6 unspecified, equivalente a 0.0.0.0) a BLOCKED_IP_NETWORKS. Verificado ejecutando las 5 evasiones conocidas.
  • Los 4 mutadores de racks/api/library.py (delete_stencil, update_stencil, create_custom_stencil, create_from_image) exigen require_perm(request, "racks", "admin"). Nueva allowlist de extensión (_ALLOWED_STENCIL_EXT: png/jpg/jpeg/gif/webp/svg); un .svg subido pasa por sanitize_svg_file (quita <script>, atributos on*) antes de guardarse, y si no es SVG parseable se borra el fichero y se responde 400. core.views.serve_media_gated añade X-Content-Type-Options: nosniff y sirve .svg/.html/.htm/.xml/.zip con Content-Disposition: attachment (mismo patrón que el camino de signage) — cierra también el gemelo /bg de blueprints.
  • signage/views.py gana _pin_unlocked(request, token, project), comprobado ahora en los 3 POST de acción además de la vista de lectura; client_publish exige adicionalmente el permiso schedule.
  • core/api/search.py: cada bloque de resultados (racks, equipos, blueprints) se gatea por su has_permission de scope de lectura correspondiente; sin el permiso, ese bloque devuelve lista vacía en vez de filtrarse después. core/middleware/module_gating.py: la comprobación de organización suspendida se adelanta a ANTES de is_exempt, con una lista de escape explícita y acotada (_SUSPENSION_ESCAPE: logout, estáticos, health, favicon) para que un tenant suspendido pueda seguir viendo su propia pantalla de “cuenta pausada” y cerrar sesión, pero nada más. core/api_help.py: help_article valida el path contra _HELP_ARTICLE_RE (solo wiki/crearack--*.md) devolviendo 403 si no matchea; help_ask/help_ask_stream ganan un cap de 30 preguntas/hora por organización (fixed_window_exceeded, fail-open si Valkey falla, para no romper el Help por una caída del backend de rate-limit).
  • 2 XSS de frontend (import Visio, walk SNMP) escapados en el editor, con cache-bust ?v=2 en sus imports.
  • 21 tests nuevos (tests/api/test_tanda9_security.py); regresión de las áreas tocadas verde con BD fresca (46 de test_rls incluidos).

Lecciones

  • Un filtro de red que distingue por FAMILIA de dirección (IPv4 vs IPv6) en vez de por DESTINO real deja un hueco en cuanto existe una forma de representar el mismo destino en la familia no cubierta — ::ffff:x.x.x.x no es un caso exótico, es la representación estándar de IPv4-en-IPv6 y cualquier librería de red la produce.
  • Un endpoint mutador nuevo necesita su permiso declarado explícitamente desde el primer commit; “solo llegan usuarios válidos” no es una guarda, es la ausencia de una.
  • Cuando se añade una guarda (PIN, permiso) a una vista de lectura, hay que preguntarse explícitamente si las vías de escritura del mismo recurso la heredan — no se hereda sola.
  • El orden de las comprobaciones en un middleware importa tanto como su presencia: “exento de módulo” y “exento de organización activa” son preguntas distintas: resolverlas con la misma condición cuela lo segundo dentro de lo primero.

Preventivos futuros

  • Los 2 XSS de frontend exigen click-test manual tras el deploy (no cubiertos por los 21 tests de backend). Pendiente de confirmar en pantalla.
  • La ayuda de usuario crearack--racks--stencils-crear ya se actualizó en el mismo PR (nota de admin + formatos de imagen admitidos) — no requiere seguimiento de este agente.
  • Los MEDIA y BAJA restantes de la Auditoría Suprema 2 siguen su curso en PLAN_ARREGLOS.md; esta tanda confirma que el triage de cola (más allá del agrupamiento temático por dominio) sigue destapando hallazgos que las tandas por dominio no cazan solas — vale la pena repetir el triage de cola tras cerrar el resto de tandas, no solo al final.

Véase también

  • [[incident—20260831—auditoria-suprema-2-tanda1-monitoring-racks]]
  • [[incident—20260831—auditoria-suprema-2-tanda3-xss-signage-editor]]
  • [[incident—20260901—auditoria-suprema-2-tanda8-workspace-token-doctor-sin-acotar]]
  • [[feature—security—auditoria-suprema-2-tanda-1-core]]
  • [[entity—core—service—has-permission]]
  • [[concept—saas—multi-tenancy]]
  • [[decision—20260401—module-gating]]
  • [[decision—20260829—sa4-g2-command-smuggling-cerrado]]