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:
- SSRF por IPv6 mapeada (
monitoring/services/net_guard.py): las direcciones::ffff:127.0.0.1y::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_blockedcomparaba el objetoipaddresscontra 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. - XSS almacenado por la librería de stencils (
racks/api/library.py,core/views.py):create_from_imageycreate_custom_stencilguardaban 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.svgo.htmlcon<script>se ejecutaba en el origen de la app en cuanto alguien navegaba a esa URL. - 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 cabeceraReferer— sin comprobar el PIN.client_publish, además, no exigía el permisoscheduleque sí calculaba la vista de lectura. - Gates de
coreincompletos:/api/searchenumeraba 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 deModuleGatingMiddleware(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/articleserví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; yhelp_ask/help_ask_stream(Gemma de pago) no tenían ningún tope de coste por organización. - 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_blockedtrataba “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:
ModuleGatingMiddlewarecalculaba 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 mismoif.
Fix aplicado
Commit 024052ce7db543e6ff35eb6b1a3e7659061a1c2e (PR #488):
net_guard._ip_blockedcalcula también la formaipv4_mappedde 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 a0.0.0.0) aBLOCKED_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) exigenrequire_perm(request, "racks", "admin"). Nueva allowlist de extensión (_ALLOWED_STENCIL_EXT: png/jpg/jpeg/gif/webp/svg); un.svgsubido pasa porsanitize_svg_file(quita<script>, atributoson*) antes de guardarse, y si no es SVG parseable se borra el fichero y se responde 400.core.views.serve_media_gatedañadeX-Content-Type-Options: nosniffy sirve.svg/.html/.htm/.xml/.zipconContent-Disposition: attachment(mismo patrón que el camino de signage) — cierra también el gemelo/bgde blueprints. signage/views.pygana_pin_unlocked(request, token, project), comprobado ahora en los 3 POST de acción además de la vista de lectura;client_publishexige adicionalmente el permisoschedule.core/api/search.py: cada bloque de resultados (racks, equipos, blueprints) se gatea por suhas_permissionde 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 deis_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_articlevalida elpathcontra_HELP_ARTICLE_RE(solowiki/crearack--*.md) devolviendo 403 si no matchea;help_ask/help_ask_streamganan 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=2en sus imports. - 21 tests nuevos (
tests/api/test_tanda9_security.py); regresión de las áreas tocadas verde con BD fresca (46 detest_rlsincluidos).
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.xno 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-crearya 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]]