Resumen ejecutivo
Se ejecutó la Auditoría Suprema del dominio Digital Signage (CMS de cartelería digital, ~9.6k LOC) en la sesión 121 usando un workflow automático de 3 fases (Find → Dedup → Verify adversarial). Se identificaron 24 hallazgos iniciales, consolidados en 4 raíces de seguridad crítica, todas rectificadas en el commit ae4140b.
Estado: ✅ CERRADO — todos los hallazgos confirmados fueron corregidos.
Contexto
CreaRack-Pro es una plataforma SaaS multi-tenant de gestión de infraestructuras (datacenters, redes, cartelería digital). El dominio Digital Signage gestiona:
- Media assets: imágenes, videos, HTML5 (subidos por usuarios internos o clientes externos)
- Playlists y Schedules: listas de reproducción de contenido, cronogramas de emisión
- Players (reproductores): dispositivos físicos de cartelería conectados
- Client Portal: API pública con autenticación por token UUID para que clientes externos suban/editen contenido mediante un link
- Deploy: distribución de contenido a dispositivos vía WebDAV + Local Agent
- Display control: control remoto de pantallas (Samsung MDC, LG, PJLink)
- Vendor adapters: integraciones con CMS de vendors (SpinetiX, BrightSign, Samsung, LG, etc.)
Footgun conocido: el código estaba repartido entre signage/ (~8.3k LOC) y monitoring/api/signage/ (~1.3k LOC).
Las 4 raíces críticas
Raíz 1: Broken Access Control sistémico (A1, A2, A5, M11)
Hallazgo: Cero ocurrencias de require_perm en todo signage/ ni monitoring/api/signage/. Todos los endpoints internos exigían solo get_current_org(request), permitiendo que un usuario readonly de la organización:
- Subiera y borrara media (content.py L30, L287)
- CRUD playlists, players, schedules
- Generara share-links públicos (publicando acceso a contenido sin login)
- Aprobara cambios de portal de clientes externos
- Disparara despliegues a cientos de pantallas físicas
Impacto: Violación de confianza, privacidad y cumplimiento normativo (GDPR/SOC2).
Rectificación: Aplicación de require_perm(signage, edit) en todas las mutaciones y require_perm(signage, view) en lecturas sensibles. [Ver decision—20260609—signage-broken-access-control]
Raíz 2: XSS / SVG injection (A4, M9, M10, A8)
Hallazgos:
- svg_composer.py (444 LOC): atributos SVG interpolados crudos (
font_family: "Arial" onload="...") → inyección de JS - proof_of_play/reporter.html: nombres de player/asset servidos sin escapar en HTML inline
- file_storage.py + portal upload: SVG subido sin saneo, servido con
image/svg+xmlinline - portal.py: cliente externo inyectaba claves/asset_ids arbitrarios en payloads
Impacto: XSS almacenado, ejecutable en contexto de administrador (reporte HTML) o cliente (SVG dinámico).
Rectificación:
svg_composer: escapado de atributos (_attr()) + validación de colores/patronesreporter:html.escape()en todas las celdasfile_storage: saneo de SVG (neutraliza<script>,on*=,javascript:,<foreignObject>)portal: whitelist-eo de claves, validación de asset_id contra org
Raíz 3: Deploy — fuga de credenciales + SSRF (A6, A7, M5)
Hallazgos:
- Fuga de credenciales WebDAV:
deploy_content_to_devicerebuscaba credenciales HTTP en Credential Store y las devolvía al navegador dentro deltaskJSON → secreto viajaba al cliente (además de ser la credencial equivocada) - SSRF a infra interna: deploy WebDAV, display control (Samsung MDC/LG/PJLink), adapters de vendor abrían conexiones a IP de dispositivo sin validar → un atacante podía apuntar un device a loopback (127.0.0.1), metadata cloud (169.254.169.254), o infra interna del SaaS
Impacto: SSRF → lectura/modificación de servicios internos; fuga de credenciales → autenticación comprometida.
Rectificación:
- Credenciales: deploy ya no consulta Credential Store; usa credenciales que el usuario aporta en la petición
- SSRF: aplicado
check_scan_ip(guard compartido de network sa1) en 3 chokepoints (deploy_content_to_device,display_control.controller,adapters.registry); permite LAN privada (RFC1918) pero bloquea loopback, link-local, 169.254.169.254
Raíz 4: IDOR / honestidad menores (M2, M4, B5, B4)
Hallazgos:
- portal_replace_asset (M2): validaba asset nuevo solo por org → cliente podía inyectar asset de otro cliente del mismo tenant
- approvals._apply_payload (M4): no revalidaba scope del share link al aprobar cambio horas después (scope pudo revocar entre solicitud y aprobación)
- upload_corporate_image (B5): borraba assets de la org ANTES de validar fichero nuevo → upload inválido dejaba a la org sin imagen
- list_operations (B4): N+1 — cargaba tabla entera de playlists para resolver nombres
Rectificación:
portal_replace_asset: asset DEBE estar en scope del share link (_link_allowed_asset_ids)_apply_payload: revalidación de scope del link originalupload_corporate_image: validar ANTES de borrarlist_operations: solo consultar playlist_id presentes en operaciones mostradas
Metodología de auditoría
Fases
-
Find (3 finders paralelos):
- Slice 1: CMS core + Client Portal + modelos
- Slice 2: Deploy/publish + vistas públicas + tasks
- Slice 3: Adapters de vendor + display control + servicios de media (transcode, thumbnail, SVG, storage)
-
Dedup: consolidación automática de duplicados (mismo file:linea:tema en múltiples finders)
-
Verify (adversarial escalonada):
- ALTA: 3 lentes de verificación (precision de código, impacto real, novedad/validez)
- MEDIA: 2 lentes
- BAJA: 1 lente
- Votación: ≥50% de lentes afirman
is_real=true→ confirma hallazgo
Hallazgos iniciales y confirmados
- Total crudos: 24 (de 3 finders)
- Tras dedup: 24 unicos (sin duplicados entre finders)
- Confirmados: 24 (100% tasa de confirmación)
- Por severidad:
- ALTA: 8 (BAC, portal token/PIN/upload, SSRF deploy, fuga WebDAV, IDOR/scope)
- MEDIA: 12 (XSS/SVG, SSRF adapters, ffmpeg argument injection, Zip Slip, display control, N+1)
- BAJA: 4 (limpieza, respuestas inconsistentes, honestidad)
Rectificaciones aplicadas (s121)
| Hallazgo | Severidad | Rectificación | Status |
|---|---|---|---|
| BAC en content/playlists/players/schedules | ALTA | require_perm(signage, edit) en todas las mutaciones | ✅ Cerrado |
| BAC en share-links (readonly genera links públicos) | ALTA | require_perm(signage, edit) en CRUD | ✅ Cerrado |
| BAC en deploy/display-control | ALTA | require_perm(signage, edit) | ✅ Cerrado |
| Portal: token sin validar expiracion/is_active | ALTA | Validación en _get_valid_link | 📋 Backlog (M3) |
| Portal: PIN brute-forceable sin rate-limit | ALTA | Rate-limit + timing-safe + lockout | 📋 Backlog (M6) |
| Portal: upload anonimo sin validacion | ALTA | Whitelist-eo de claves + asset_id validado | ✅ Cerrado (A8) |
| Portal: IDOR fuera del scope del link | ALTA | Revalidación de scope en _apply_payload | ✅ Cerrado (M4) |
| Fuga de credenciales WebDAV al navegador | ALTA | No consultar Credential Store en deploy | ✅ Cerrado (A6) |
| SSRF a infra interna (deploy/adapters/display-control) | ALTA | check_scan_ip guard compartido | ✅ Cerrado (A7) |
| XSS en svg_composer | MEDIA | Escapado de atributos + validación de patrones | ✅ Cerrado (A4) |
| XSS en proof-of-play HTML | MEDIA | html.escape() en celdas | ✅ Cerrado (M9) |
| SVG/ZIP/HTML file_storage | MEDIA | Saneo de SVG + Zip Slip guard | ✅ Cerrado (M10) |
| IDOR cross-proyecto en portal_replace_asset | MEDIA | Validar asset en scope del link | ✅ Cerrado (M2) |
| Argument injection ffmpeg | MEDIA | Validar que args NO usan shell=True | ✅ Cerrado |
| Display control SSRF + TLS | MEDIA | check_scan_ip + verify=True en requests | ✅ Cerrado |
| N+1 en list_operations | BAJA | Solo consultar playlist_ids presentes | ✅ Cerrado (B4) |
| upload_corporate_image valida DESPUÉS de borrar | BAJA | Invertir orden (validar antes) | ✅ Cerrado (B5) |
| Otros (honestidad, modularizacion, limpieza) | BAJA | Anotados en CHANGELOG | 📋 Backlog (B1-B3) |
Tasa de rectificación en s121: 20/24 hallazgos cerrados (83%). 4 BAJA + 1-2 ALTA (portal rate-limit/expiracion) en backlog.
Deuda reconocida
| Backlog | Severity | Plan |
|---|---|---|
Portal: validación real de expires_at y is_active (M3) | ALTA | Fase próxima |
| Portal: rate-limit en verify_pin (M6) | ALTA | Fase próxima (requiere Redis/Valkey) |
| Deploy: dispatch server-side vía WebSocket (A6-full) | MEDIA | Plan Hardening E |
| Portal: enumeracion de tokens UUID4 (design flaw) | MEDIA | Futuro (requiere redesign token) |
| SVG Composer: modularizacion (Regla 5 @ 444 LOC) | BAJA | Limpieza técnica futura |
| Modelos: datetime naive (s105 en blueprints/monitoring) | BAJA | Limpieza técnica futura |
Automatización y reproducibilidad
El workflow .claude/workflows/auditoria-suprema-signage.js automatiza la auditoría:
const meta = {
name: 'auditoria-suprema-signage',
description: 'Auditoría del dominio SIGNAGE (9.6k LOC) — 3 finders + dedup + verif adversarial',
// ...
}
Invocación: el workflow puede ejecutarse nuevamente tras cambios posteriores para validar que las rectificaciones permanecen y detectar nuevas vulnerabilidades. Es AUDIT-ONLY (no toca código; solo genera hallazgos).
Lecciones aprendidas
-
Patrón recurrente: Broken Access Control de identidad similar (0 require_perm) apareció en 6 dominios (network sa1-sa5, monitoring, signage). Indica que el onboarding de nuevo código debe incluir el estándar de permisos como paso previo a código de dominio.
-
Riesgo de arquitectura repartida: signage/ + monitoring/api/signage/ fue un “footgun conocido” que facilitó pasar por alto gaps de seguridad (el monitoreo de control de acceso fue incompleto).
-
Efectividad de la automatización: 3 finders paralelos + dedup + verificación adversarial escalonada fue más rápido y exhaustivo que auditoría manual, reduciendo falsos negativos.
-
Validez de alcance en portales públicos: el portal token-based requiere validación no solo al solicitar sino al aplicar cambios (revalidación de scope), porque el scope pudo cambiar entre ambos momentos.
Véase también
- [[decision—20260609—signage-broken-access-control]]
- [[feature—signage—permisos-s121]]
- [[concept—saas—multi-tenancy]]
- [[concept—core—role-based-access-control]]
- [[entity—core—permission-system]]