s121: Digital Signage — permisos por rol y cierre de 4 raíces de seguridad
Resumen
Sesión 121 (2026-06-09) cierra el módulo de Digital Signage CMS (cartelería digital) con un conjunto completo de parches de seguridad derivados de la Auditoría Suprema dominio signage (commit ae4140b). Implementa el modelo de permisos por rol que ya se aplicaba en racks, networking, monitorización y ITSM, y remedia 4 raíces de vulnerabilidad de alto impacto:
- Broken Access Control sistemático — 0 ocurrencias de
require_permen todo signage/ y monitoring/api/signage/ - XSS / SVG injection — múltiples vectores (svg_composer, proof_of_play HTML, SVG subido)
- Deploy: fuga de credenciales WebDAV + SSRF a infra interna
- IDOR y defectos menores — cross-proyecto de assets, upload_corporate_image, N+1
El cambio es transversal a TODO el CMS: mutaciones (content/playlists/players/schedules/share-links/approvals/deployments/display-control) exigen require_perm(signage, edit); lecturas sensibles (reports, deep-data) exigen require_perm(signage, view).
1. Broken Access Control Sistemático (Raíz 1)
Problema
El dominio signage no tenía gate de autorización alguno en los endpoints internos. Todos se basaban únicamente en get_current_org(request) (la organización del usuario autenticado), lo que significaba que:
- Un usuario readonly (rol con permiso mínimo) podía:
- ✅ Subir, borrar, reedicar media assets
- ✅ Crear/borrar playlists, players, schedules
- ✅ Generar y revocar share-links públicos (exponer contenido a clientes externos)
- ✅ Aprobar cambios de clientes remotos
- ✅ Lanzar deploys reales a dispositivos
- ✅ Controlar pantallas (Samsung MDC, LG, PJLink)
Solución
Registrar y aplicar el scope signage en el sistema de permisos de roles (core/utils/permissions.py):
- admin:
signage: admin - operator:
signage: edit - readonly:
signage: view
Puertas cerradas:
-
signage/api/content.py: mutaciones (upload_media,create_url_asset,update/delete/bulk_delete,reprocess,transcode_status,regenerate_thumbnail) exigenrequire_perm(signage, edit). -
signage/api/playlists.py,players.py,schedules.py: CRUD exigerequire_perm(signage, edit). -
signage/api/share_links.py: crear/borrarClientShareLinkexigerequire_perm(signage, edit)(un readonly ya no genera links públicos). -
signage/api/approvals.py: aplicar cambios pendientes del portal exigerequire_perm(signage, edit). -
signage/api/deployments.py: crear deployment (create_deployment) exigerequire_perm(signage, edit). -
signage/api/display_control.py: todas las mutaciones (power, input, brightness) exigenrequire_perm(signage, edit). -
signage/api/proof_of_play.py: listados sensibles (deep-data con SNMP reales) exigenrequire_perm(signage, view). -
monitoring/api/signage/deploy.py:deploy_content_to_device,restore_operation,upload_corporate_imageexigenrequire_perm(signage, edit). -
monitoring/api/signage/dashboard.py,reports.py,summary.py,discovery.py,groups.py: endpoints que leen deep-data o generan reports exigenrequire_perm(signage, view).
Impacto
- Un readonly del CMS ya no puede alterar contenido ni lanzar cambios a producción.
- El permiso manda (modelo consistente con el resto del producto).
- Las lecturas sensibles (reports SNMP) quedan protegidas de enumeración/exfiltración por usuarios sin rol explícito.
2. XSS y SVG Injection (Raíz 2)
Problema
El módulo generaba y servía SVG dinámico e interpolaba contenido HTML sin sanear, creando 3 vectores de XSS:
2a. svg_composer.py (444 LOC) — atributos de usuario interpolados crudos en SVG:
font_family:"Arial" onload="alert(1)"→ inyección de atributocolor,bg_color,font_weight,text_anchor: escapada insuficiente- El SVG se servía como
image/svg+xml(inline, ejecuta scripts)
2b. proof_of_play/reporter._to_html — nombres de assets/playlists sin escapar en HTML:
- Un asset subido con nombre
<img src=x onerror=alert(1)>se interpolaba directamente en celdas de tabla - Servido como
text/html→ XSS almacenado
2c. SVG/HTML/Zip subido por cliente anónimo — activos controlados por usuario sin validación de contenido:
- Un atacante sube un SVG con
<script>embebido en el portal público - El SVG se sirve después a otros usuarios → XSS cross-tenant
Solución
-
svg_composer.py: escapar o validar contra patrón estricto:_attr(value): HTML-escape de atributos_safe_color(hex): validar contra patrón^#[0-9a-f]{6}$_safe_anchor(value): whitelist destart | middle | end_safe_weight(num): validar en rango 100–900
-
proof_of_play/reporter._to_html: usarhtml.escape()en todas las celdas de entrada de usuario. -
file_storage.save_upload: saneo defensivo de SVG cargado (defensa en profundidad):- Neutralizar
<script>,on*=(event handlers),javascript:URIs,<foreignObject>(XML embebido) - Best-effort contra polyglot maliciosos (SVG + JS embebido difícil de sanear al 100 %)
- Neutralizar
Impacto
- Cierra XSS stored en reportes
- Reduce XSS en SVG dinámico (corporativo)
- Mitiga carga de activos maliciosos vía portal anónimo
3. Deploy: Credenciales WebDAV y SSRF a Infra Interna (Raíz 3)
Problema
3a. Fuga de credenciales WebDAV al navegador (monitoring/api/signage/deploy.py):
deploy_content_to_deviceleía credenciales HTTP (username/password) del Credential Store global de la org- Las credenciales viajaban en claro en la respuesta JSON al browser (
task.credentials) - Un usuario podía descubrir secretos que ni conocía (credenciales guardadas previamente por admin)
3b. SSRF a infra interna sin validación:
- El deploy abre conexión WebDAV a la IP del device (
device.device_ip) - Control de display (Samsung MDC, LG, PJLink) abre sockets TCP a esa IP
- Adapters de vendor (SpinetiX, BrightSign, etc.) hacen requests HTTP a URLs del vendor/device
- Ninguno validaba
is_private_ip: un dispositivo apuntado a169.254.169.254(AWS metadata),127.0.0.1, o una IP de infra interna (PgBouncer, VictoriaMetrics) forzaría conexiones del servidor desde dentro de la VPC.
Solución
-
No enviar credenciales al navegador:
deploy_content_to_deviceahora solo acepta credenciales que el usuario aporta en la petición (si la usa)- El Credential Store global se retira de este flujo (backlog completo: agent dispatch vía WebSocket sin pasar credenciales)
-
SSRF guard en 3 chokepoints (
check_scan_ipde network sa1):deploy_content_to_device: validadevice.device_ipdisplay_control.controller: valida IP antes de abrir socket TCPadapters.registry.get_adapter: valida antes de hacer requests HTTP a vendor/device
Política:
- ✅ RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) — legítimo (on-prem del cliente)
- ✅ CGNAT (100.64.0.0/10) — legítimo (on-prem del cliente)
- ❌ Loopback (127.0.0.0/8) — bloquea SSRF a sí mismo
- ❌ Link-local (169.254.0.0/16) — bloquea AWS metadata (169.254.169.254)
- ❌ Multicast, broadcast, reserved
Impacto
- Elimina fuga de credenciales al cliente web
- Mitiga SSRF a servicios internos de la SaaS (metadata cloud, monitoring, base de datos)
- Permite on-prem legítimo (clientes con dispositivos en red local)
4. IDOR y Defectos Menores (Raíz 4)
4a. IDOR: Cross-proyecto de assets en portal (M2)
Problema: portal_replace_asset validaba el asset nuevo solo por organización — un cliente externo podía colocar cualquier asset de su org (incl. de otros clientes/proyectos) en su playlist.
Solución: El asset debe estar en el scope del ClientShareLink:
- Activos ya referenciados en sus playlists permitidas
- Activos que el propio cliente subió por el portal (nuevo helper
_link_allowed_asset_ids)
4b. Revalidación de scope al aprobar cambios del portal (M4)
Problema: approvals._apply_payload aplicaba cambios (playlist_id/schedule_id) sin comprobar que seguían dentro del scope del ClientShareLink original (el scope pudo cambiar entre petición y aprobación).
Solución: _apply_payload ahora valida que el playlist/schedule del cambio sigue en el scope del link antes de aplicar.
4c. IDOR en create_deployment (M8)
Problema: deployments.py validaba los player_ids por organización pero persistía playlist_id/schedule_id crudos sin validar que pertenecieran a la org.
Solución: Rechaza (400) un playlist/schedule que no pertenezca a la org antes de crear el ContentDeployment.
4d. upload_corporate_image borra antes de validar (B5)
Problema: El endpoint borraba TODAS las corporate assets de la org antes de validar/guardar el fichero nuevo — un upload inválido dejaba a la org sin imagen corporativa.
Solución: Guardar y validar primero, borrar después (comportamiento idempotente).
4e. N+1 en list_operations (B4)
Problema: monitoring/api/signage/dashboard.py:list_operations cargaba la tabla entera de playlists de la org para resolver nombres de playlist en las operaciones.
Solución: Solo consultar los playlist_id presentes en las 50 operaciones mostradas (paginación por defecto).
Cambios Técnicos Clave
| Componente | Cambio | Efecto |
|---|---|---|
core/utils/permissions.py | Añadir scope signage (admin/edit/view) | Todos los gates ahora reconocen el módulo |
signage/api/*.py | Decorador @require_perm(signage, edit/view) en endpoints internos | Mutaciones bloqueadas a readonly |
monitoring/api/signage/*.py | Ídem + gates en deploy/dashboard/reports | Deploy bloqueado a readonly |
svg_composer.py | Escapar atributos; validar colores/anchors | Cierra XSS en SVG dinámico |
proof_of_play/reporter.py | html.escape() en celdas HTML | Cierra XSS stored en HTML |
file_storage.py | Saneo defensivo de SVG subido | Mitiga carga maliciosa |
deploy.py | Quitar lectura de Credential Store; aplicar check_scan_ip | Evita fuga de creds + SSRF |
display_control/ | Aplicar check_scan_ip en sockets TCP | Mitiga SSRF local |
adapters/ | Aplicar check_scan_ip en requests HTTP | Mitiga SSRF a vendors |
portal.py | Validar scope en portal_replace_asset + _can_edit | Mitiga IDOR fuera de scope |
approvals.py | Revalidar scope en _apply_payload | Scope no se mueve entre petición y aprobación |
deployments.py | Validar org de playlist/schedule | Rechaza IDOR en deployment |
Entrada en RELEASE_NOTES (s121)
El módulo de cartelería digital (Digital Signage) ahora respeta los roles de usuario como el resto del producto. Hasta ahora cualquier persona con acceso al módulo —incluido un usuario de solo lectura— podía hacer de todo: subir y borrar contenido, crear y eliminar listas de reproducción y pantallas, generar enlaces públicos para clientes externos, aprobar cambios e incluso lanzar despliegues a los dispositivos. A partir de ahora, solo quien tiene permiso de edición puede hacer esos cambios; los usuarios de solo lectura pueden ver pero no tocar. Es el mismo modelo de permisos que ya aplicábamos en racks, redes y monitorización. Sale de la auditoría de seguridad del dominio signage (parte de la Auditoría Suprema).
Pruebas Recomendadas
-
Authz (require_perm):
- readonly no puede POST/PUT/DELETE en signage/* endpoints
- readonly no puede lanzar deploy, restaurar operaciones
- readonly CAN GET listados y reportes (si tienen
signage: view)
-
Portal público (ClientShareLink):
portal_replace_asset: asset no permitido → 403 (antes 200)portal_update_playlist: playlist fuera del scope → 403- Scope no se ensancha entre petición y aprobación
-
SSRF:
- device_ip = 127.0.0.1 → deploy rechazado (antes podría intentarse)
- device_ip = 169.254.169.254 → rechazado
- device_ip = 192.168.1.1 → permitido (on-prem)
-
XSS / SVG:
- SVG con
<script>subido → neutralizado (sin<script>en respuesta) - Proof-of-play HTML con asset malicioso → escapado
- svg_composer con
font_family="Arial" onload=…"→ escapado
- SVG con
-
IDOR:
- corporate_image subido inválido → no se borran assets previos
- list_operations no genera N+1 (verificar con django-debug-toolbar)
Linaje y Contexto
Esta sesión es parte de la Auditoría Suprema 2026 (iniciativa mayor de hardening de dominio en dominio). Las raíces remediadas aquí se han confirmado reales con verificación adversarial en 3 lentes (precisión de código, impacto real, novedad/validez) para ALTA y MEDIA.
El patrón de Broken Access Control (clave 1) es el mismo que se identificó y se cerró en 5 dominios previos (network sa1–sa5, monitoring, core) — es válido reportarlo como hallazgo NUEVO en signage porque el módulo sí carecía de gates.
Véase también
- [[concept—saas—multi-tenancy]]
- [[entity—core—model—organization]]
- [[concept—seguridad—broken-access-control]]
- [[feature—monitoring—require-perm-network]]
- [[entity—signage—model—clientsharelink]]