Fuga cross-tenant en el publicador anónimo de signage (playlist_id sin validar)
Cuándo
Hallazgo señalado por una auditoría interna de aislamiento multi-tenant y listado como pendiente de verificación independiente en AUDIT.md (sección “Hallazgos multi-tenant SIN VERIFICAR”), apuntando a signage/api/schedules.py:53,102. Verificado y cerrado el 23-08-2026 en la ronda de mantenimiento de signage — commit 49904a3d (PR #420), versión v1.82.2, task #248.
Síntomas visibles
Ninguno reportado por clientes. El hallazgo nació de una auditoría de código, no de un incidente en producción; no consta evidencia de explotación real.
Causa raíz
El publicador de signage (signage/views_publish.py, función _get_playlist_items) es una ruta anónima: el middleware de Row Level Security exime explícitamente a las peticiones sin autenticar (bypass app.current_org_id = '0', ver [[decision—20260403—multi-tenancy-rls]]). Esa ruta resolvía el horario activo del reproductor (evaluate_schedule) y usaba el playlist_id devuelto tal cual para buscar la Playlist, sin filtrar por organización:
playlist = Playlist.objects.filter(id=result["playlist_id"]).first()
El playlist_id vive dentro del JSON libre del campo rules de Schedule. Nada impedía que ese número apuntara a una playlist de otra organización — ni por error al guardar, ni de forma deliberada si alguien conocía o adivinaba un ID ajeno. Sin el filtro, el reproductor podía acabar emitiendo los textos, rótulos y colores de la playlist de otro cliente.
El borde autenticado tampoco cerraba la puerta: al crear o editar un Schedule (signage/api/schedules.py), nada validaba que los playlist_id citados en las reglas pertenecieran a la organización del usuario — un horario podía guardarse apuntando a una playlist ajena sin ningún aviso, y el problema solo se manifestaba más tarde, en el publicador.
Fix aplicado
Commit 49904a3d (PR #420, v1.82.2), doble barrera:
signage/views_publish.py— el filtro del publicador ahora exigeorganization=player.organization.signage/api/schedules.py— nueva función_validate_playlists_belong_to_org(), invocada encreate_scheduleyupdate_schedule: responde 400 si algúnplaylist_id(de las reglas o deldefault_playlist_id) no pertenece a la organización actual.
Se cierra tanto en el borde de guardado (autenticado) como en el borde de lectura (anónimo, sin red de seguridad de RLS por debajo).
Lecciones
- Un ID que viaja dentro de un JSON libre (
Schedule.rules) es tan peligroso como un parámetro de URL: no basta con que el modelo contenedor tenga FK aorganization, cada ID referenciado dentro necesita su propio filtro. - Las rutas anónimas (eximidas de RLS por diseño, para que los reproductores puedan pedir contenido sin credenciales) son las que menos margen de error tienen: no hay red de seguridad de base de datos por debajo, así que el filtro en código es la única barrera real.
- Patrón hermano sin cerrar: la Auditoría Suprema de signage (
supercontext/auditoria-suprema/signage.md, hallazgo M8) señala el mismo problema ensignage/api/deployments.py(create_deploymentaceptaplaylist_id/schedule_idcrudos sin validar pertenencia a la org) — mismo síntoma, endpoint distinto, todavía abierto.
Preventivos futuros
- Cerrar el hallazgo M8 de la Auditoría Suprema (
signage/api/deployments.py) con el mismo patrón de validación aplicado aquí. - Al añadir un ID nuevo a un JSON libre que un borde anónimo pueda resolver, el checklist de revisión de PR debería preguntar explícitamente: “¿este ID se valida contra la organización antes de usarse?”.
Véase también
- [[entity—signage—model—playlist]]
- [[entity—signage—model—mediaasset]]
- [[entity—signage—model—signageplayer]]
- [[concept—saas—multi-tenancy]]
- [[decision—20260403—multi-tenancy-rls]]
- [[incident—20260611—fuga-cross-tenant-llm]]
- [[incident—20260611—r6-cross-tenant-read-write-legacy]]
- [[crearack-tech—guides—security-guide]]