Contexto y problema
La Auditoría Suprema del dominio Digital Signage (audit-only, .claude/workflows/auditoria-suprema-signage.js) identificó un patrón crítico de seguridad que coincidía en todos los dominios previos auditados (network sa1-sa5, monitoring, racks): Broken Access Control sistémico.
Hallazgo principal
El módulo signage (9.6k LOC repartidas entre signage/ y monitoring/api/signage/) no contenía ni una sola ocurrencia de require_perm. Todos los endpoints internos exigían solo get_current_org(request) (obtener la organización del usuario). Esto significaba:
# Síntoma: un readonly podía hacer ESTO
POST /api/signage/content/upload_media/
→ upload_media(org=user.org) — SOLO filtra por org, no por rol
→ readonly puede subir media ✗
# Se suponía que debería ser
POST /api/signage/content/upload_media/
→ require_perm(request, "signage", "edit") — primero valida permiso
→ si readonly → 403 Forbidden
# Síntoma: readonly generaba links públicos
POST /api/signage/share-links/
→ create_share_link(org=user.org) — SOLO org
→ readonly genera link público a contenido ✗
# Síntoma: readonly disparaba despliegues
POST /api/signage/deployments/
→ create_deployment(org=user.org) — SOLO org
→ readonly dispara deploy a 100 pantallas públicas ✗
Gravedad
Severidad: ALTA — control de acceso es fundamental para la seguridad multi-tenant. Un usuario readonly es una persona con permiso restringido deliberado (auditor, asistente de solo lectura, usuario de cliente externo); que pueda mutar datos viola confianza, cumplimiento normativo (GDPR/SOC2) e integridad de negocio.
Decisión
Cerrar el Broken Access Control aplicando el estándar require_perm en TODOS los endpoints de signage.
El sistema ya disponía del mecanismo desde fases anteriores (core/utils.require_perm(request, scope, action)) tras desplegarse en 5 dominios previos. No es una nueva abstracción; es aplicar lo que ya funciona.
Estándar adoptado
from core.utils import require_perm
# Dentro de cada endpoint que MUTA
def upload_media(request, content_in):
org = require_perm(request, "signage", "edit").org # 403 si readonly
media = MediaAsset.objects.create(organization=org, ...)
return {"ok": True, "asset_id": media.id}
# Dentro de cada endpoint que LEE datos SENSIBLES (reportes, datos SNMP)
def get_deep_snmp_data(request, player_id):
org = require_perm(request, "signage", "view").org # 403 si sin permiso
player = SignagePlayer.objects.get(pk=player_id, organization=org)
return {"player": player, "snmp_data": fetch_snmp(player.ip)}
Rol-permission mapping (core/utils/permissions.py):
admin: signage →edit(crear/editar/borrar todo, aprobar cambios de portal, deploy)operator: signage →edit(idem)readonly: signage →view(leer datos, NO mutar)
Cambios implementados (raíces 1-4 de auditoría)
Raíz 1: Broken Access Control en endpoints internos
| Categoría | Ejemplos | Fix |
|---|---|---|
| Content | upload_media, delete_media, bulk_delete_media, transcode_media, regenerate_thumbnail | require_perm(signage, edit) |
| Playlists | create_playlist, update_playlist, delete_playlist, add_item, remove_item | require_perm(signage, edit) |
| Players | create_player, update_player, delete_player | require_perm(signage, edit) |
| Schedules | create_schedule, update_schedule, delete_schedule | require_perm(signage, edit) |
| Share links | create_share_link, update_share_link, delete_share_link | require_perm(signage, edit) |
| Approvals | approve_change, reject_change | require_perm(signage, edit) |
| Deployments | create_deployment, delete_deployment, trigger_deploy | require_perm(signage, edit) |
| Display control | samsung_mdc_command, lg_protocol_command, pjlink_command | require_perm(signage, edit) |
| Deep reads | get_deep_snmp_data, list_reports, get_sync_status | require_perm(signage, view) |
Raíz 2: XSS / SVG injection (secundario a BAC, pero crítico)
- svg_composer.py: atributos interpolados crudos (
font_family,color,bg_color) → escapados con_attr()o validados contra patrón (_safe_color, etc.) - proof_of_play/reporter.html: nombres de player/asset/playlist sin escaping → aplicado
html.escape() - file_storage.py: SVG subido servido inline → saneo de contenido activo (
<script>,on*=,javascript:,<foreignObject>) - portal.py: cliente externo inyectaba assets/claves arbitrarias → whitelist-eo de claves y validación de asset_id contra org
Raíz 3: Deploy - credenciales + SSRF
- Fuga de credenciales WebDAV:
deploy_content_to_devicebuscaba credenciales en Credential Store y las devolvía en claro al navegador → ahora usa SOLO credenciales que el usuario proporciona en la petición; Credential Store ya no se consulta (backlog: server-side dispatch vía WebSocket) - SSRF a infra interna: deploy WebDAV, display control (Samsung MDC/LG/PJLink) y adapters de vendor abrían conexiones a IP de dispositivo sin validar → aplicado
check_scan_ip(guard compartido de network sa1): permite LAN privada del cliente (RFC1918/CGNAT on-prem) pero bloquea loopback, link-local, metadata cloud (169.254.169.254)
Raíz 4: IDOR / honestidad menores
- Inyección cross-proyecto en portal:
portal_replace_assetvalidaba asset solo por org → ahora DEBE estar en el scope del share link - upload_corporate_image: borraba antes de validar → ahora valida antes de borrar
- N+1 en list_operations: cargaba tabla entera de playlists → ahora solo consulta los playlist_id presentes en las 50 operaciones mostradas
Validación de scope del portal (M4)
El portal de clientes (ClientShareLink token-based) permite que un cliente externo suba/edite contenido mediante un link con token público y acceso limitado. La auditoría identificó que la validación del scope se hacía al originarse el cambio pero NO al aprobarlo:
# Flujo del portal cliente
1. Cliente externo carga portal vía link (token)
→ _get_valid_link(token) obtiene ClientShareLink
→ scope: allowed_playlists = [1, 2, 3], allowed_players = [A, B, C]
2. Cliente sube media y solicita que se añada a playlist #1 (en scope)
→ approvals.create(playlist_id=1, ...) ← OK
3. [HORAS DESPUÉS] La scope cambió: allowed_playlists = [] (link revocado)
→ admin aprueba el cambio
→ approvals._apply_payload(playlist_id=1) — NO validaba scope de nuevo
→ media se añade a playlist #1 aunque el link ya no tiene acceso ✗
Fix (M4): _apply_payload ahora revalida que el playlist_id/schedule_id sigue dentro del scope del link original.
Justificación
- Consistencia: el estándar ya funciona en network, racks, monitoring. Aplicarlo en signage no introduce nuevo código.
- Seguridad multi-tenant: fundamental para SaaS. Un readonly deliberado es una restricción, no una preferencia de UI.
- Cumplimiento: SOC2/GDPR exigen control de acceso basado en roles; esto lo implementa.
- Bajo coste:
require_permes un guard de 2-3 líneas; ya estaba todo el terreno preparado. - Precedente: 5 dominios previos ya lo tienen. Signage era la excepción.
Riesgos y mitigación
| Riesgo | Mitigación |
|---|---|
| Scripts/bots esperaban readonly sin gates | CHANGELOG claro en s121; endpoint ahora 403 |
| Admin tiene que otorgar permisos editores a usuarios que antes no tenían | Documentación de migración (RELEASE_NOTES); check en bootstrap de org |
| Rendimiento: +1 query por endpoint para validar permiso | require_perm usa get_current_org (ya cacheada en request); impacto nulo |
Trabajo futuro (backlog)
- A6-full: dispatch server-side vía WebSocket del Agent para no pasar credenciales WebDAV por navegador
- Portal rate-limit: PIN brute-forceable sin rate-limit (audit identifica; no incluyó fix aún)
- Portal expiracion real:
ClientShareLink.expires_atno se valida en_get_valid_link(audit identifica; backlog)
Véase también
- [[feature—signage—permisos-s121]]
- [[concept—saas—multi-tenancy]]
- [[concept—core—role-based-access-control]]
- [[incident—audit-suprema-signage]]
- [[entity—core—permission-system]]
Referenciado desde
- Auditoría Suprema — dominio Digital Signage (24 hallazgos, 4 raíces)
- Auditoría Suprema 2 · Tanda 3: XSS almacenado en el portal de signage, el composer SVG y el editor
- Cerrar Broken Access Control en el CRUD de racks y en /api/settings
- El PIN del portal de clientes de Signage se guardaba en claro en la base de datos
- Permisos por rol en Digital Signage (s121)
- Servicio publish_misses — enlaces de publicación de Signage rechazados (v1.124.0, task #276)