CreaRack-SL

Cerrar Broken Access Control sistémico en Digital Signage

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íaEjemplosFix
Contentupload_media, delete_media, bulk_delete_media, transcode_media, regenerate_thumbnailrequire_perm(signage, edit)
Playlistscreate_playlist, update_playlist, delete_playlist, add_item, remove_itemrequire_perm(signage, edit)
Playerscreate_player, update_player, delete_playerrequire_perm(signage, edit)
Schedulescreate_schedule, update_schedule, delete_schedulerequire_perm(signage, edit)
Share linkscreate_share_link, update_share_link, delete_share_linkrequire_perm(signage, edit)
Approvalsapprove_change, reject_changerequire_perm(signage, edit)
Deploymentscreate_deployment, delete_deployment, trigger_deployrequire_perm(signage, edit)
Display controlsamsung_mdc_command, lg_protocol_command, pjlink_commandrequire_perm(signage, edit)
Deep readsget_deep_snmp_data, list_reports, get_sync_statusrequire_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_device buscaba 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_asset validaba 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

  1. Consistencia: el estándar ya funciona en network, racks, monitoring. Aplicarlo en signage no introduce nuevo código.
  2. Seguridad multi-tenant: fundamental para SaaS. Un readonly deliberado es una restricción, no una preferencia de UI.
  3. Cumplimiento: SOC2/GDPR exigen control de acceso basado en roles; esto lo implementa.
  4. Bajo coste: require_perm es un guard de 2-3 líneas; ya estaba todo el terreno preparado.
  5. Precedente: 5 dominios previos ya lo tienen. Signage era la excepción.

Riesgos y mitigación

RiesgoMitigación
Scripts/bots esperaban readonly sin gatesCHANGELOG claro en s121; endpoint ahora 403
Admin tiene que otorgar permisos editores a usuarios que antes no teníanDocumentación de migración (RELEASE_NOTES); check en bootstrap de org
Rendimiento: +1 query por endpoint para validar permisorequire_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_at no 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]]