CreaRack-SL

Auditoría Suprema 2 · Tanda 3: XSS almacenado en el portal de signage, el composer SVG y el editor

Cuándo

31-08-2026 · commit 5d158046 (PR #480, v1.94.0). Tercera tanda de la Auditoría Suprema 2 — la primera (v1.90.0/v1.91.0) cerró huecos de control de acceso en core, monitoring y racks ([[feature—security—auditoria-suprema-2-tanda-1-core]], [[incident—20260831—auditoria-suprema-2-tanda1-monitoring-racks]]). Esta entrega cambia de familia: XSS almacenado en la ruta pública (sin sesión) de Digital Signage y en el editor de racks.

Síntomas visibles

6 hallazgos ALTA + 2 MEDIA, todos en signage salvo el del editor de core:

  1. Portal público de cliente (templates/signage/client_portal.html + signage/views.py): devices_json/playlists_json se inyectaban con |safe dentro de un <script> inline, en una ruta anónima y además exenta de CSP — un nombre de playlist o un hostname con </script><script>… ejecutaba JS arbitrario en el origen de la app.
  2. Composer de SVG (signage/services/svg_composer.py): un fix previo (A4) ya saneaba color/fuente/anchor/weight, pero x/y/width/height, duration, opacity y speed se interpolaban crudos en atributos del SVG servido como image/svg+xml — y position es una clave whitelisteada que el portal anónimo puede fijar.
  3. Saneo de SVG subido por extensión, no por MIME (signage/services/file_storage.py): la neutralización de <script>/on*=/javascript: solo corría si el content_type declarado por el cliente era image/svg+xml — pero el fichero se sirve por su extensión, así que bytes SVG declarados image/png con nombre x.svg se servían con el script vivo.
  4. XSS almacenado en el editor de racks (templates/editor.html): data-devices='{{ devices_json|safe }}' en un atributo de comilla simple — json.dumps no escapa ', así que un nombre de device (editable por el tenant o venido de SNMP) podía cerrar el atributo e inyectar HTML.
  5. Panel Auto-Provision (static/js/editor/auto_provision_panel.js): pintaba name/ip/vendor/model/confidence de SNMP en innerHTML sin escapar — quedó fuera del barrido previo R5-F3.
  6. asset_ids del portal sin filtro de organización (signage/views.py): Playlist.items es JSON libre; un asset_id de otra org colado en un item filtraba su name/thumbnail/tamaño por la ruta anónima.
  7. Sin tope agregado de subida en el borde anónimo (signage/views.py): el portal aceptaba hasta 2 GB/fichero sin cuota agregada — 30 subidas/min podían llenar el disco con un solo token filtrado.

Causa raíz

Dos patrones distintos, no uno: (a) el patrón “|safe + json.dumps dentro de HTML” reaparece cada vez que una vista sirve datos del tenant a una plantilla sin pasar por json_script — ya se había corregido en páginas internas, pero el portal público y el editor quedaron fuera del barrido; (b) saneos que validan por un campo declarado por el cliente (content_type MIME) en vez de por el efectivo (extensión con la que se sirve el fichero), el mismo tipo de desajuste que ya mordió en otros puntos del proyecto.

Fix aplicado

Commit 5d158046d6c0afbb8cc9748de6b2cd6da9fd4c03 (PR #480):

  • Patrón json_script + JSON.parse(…textContent) en el portal público, eliminando los json.dumps+|safe de signage/views.py.
  • Nuevo helper _num() en svg_composer.py (coerción a numérico finito, si no default) aplicado a todo atributo de geometría, duration, opacity y speed.
  • file_storage.py sanea por extensión efectiva además de por MIME declarado.
  • media_file (views_publish.py) añade X-Content-Type-Options: nosniff y sirve .svg/.html/.htm/.zip con Content-Disposition: attachment.
  • Quitado el |safe de data-devices en editor.html (el autoescape de Django cubre ').
  • auto_provision_panel.js importa escHtml de la fuente única y escapa los 5 campos SNMP.
  • signage/views.py: organization=project.organization en el filtro de asset_ids; tope de 500 MB/fichero vía portal + cuota de 20 GB de media signage por organización.
  • 15 tests nuevos en tests/api/test_signage_xss.py.

Nota: el mismo PR incluye un fix no relacionado con seguridad — static/js/base.js tenía dos funciones globales openReportModal, y el hoisting pisaba la del menú con la de “Project Report” (Cannot read properties of undefined). Renombrada a openRestoreReportModal. Si se audita el diff de este commit por seguridad, ese hunk es ruido de otra clase de bug.

Los cambios visuales (portal, editor, Project Report) requieren verificación en pantalla tras el deploy — pendiente en el momento de este registro.

Lecciones

  • El patrón |safe+json.dumps en <script> inline hay que barrerlo por superficie de exposición (¿anónima? ¿sin CSP?), no solo por página — el portal público quedó fuera de rondas anteriores precisamente por ser la ruta menos visitada en revisión manual.
  • Saneo que confía en un campo declarado por el cliente (MIME) en vez del efectivo (extensión de servicio) es una clase de bug recurrente en el proyecto — cualquier saneo de contenido subido debe validar contra cómo se sirve, no contra cómo se anuncia.

Preventivos futuros

  • Residuales documentados como BAJA (defensa en profundidad): el innerHTML del preview del portal sigue confiando en el saneo server-side del composer; la exención de CSP en /client/ se mantiene (retirarla exige nonces y romper estilos inline); el poll de /api/jobs ×3 sin reintentos y el i18n del JS inline del portal quedan para la siguiente pasada de MEDIA/BAJA.
  • Continuar la Tanda 3+ de la Auditoría Suprema 2 sobre los dominios que falten.

Véase también

  • [[incident—20260609—audit-suprema-signage]]
  • [[decision—20260609—signage-broken-access-control]]
  • [[incident—20260823—signage-publisher-cross-tenant-playlist-leak]]
  • [[feature—security—auditoria-suprema-2-tanda-1-core]]
  • [[incident—20260831—auditoria-suprema-2-tanda1-monitoring-racks]]
  • [[entity—signage—model—mediaasset]]
  • [[crearack—signage—client-portal]]