CreaRack-SL

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 exige organization=player.organization.
  • signage/api/schedules.py — nueva función _validate_playlists_belong_to_org(), invocada en create_schedule y update_schedule: responde 400 si algún playlist_id (de las reglas o del default_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 a organization, 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 en signage/api/deployments.py (create_deployment acepta playlist_id/schedule_id crudos 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]]