Volver a la wiki

serve_media_gated: Función de servicio de media con autenticación

Definición

_PUBLIC_MEDIA_RE = re.compile(r"^signage/\d+/thumbs/[^/]+$")
_ORG_SCOPED_MEDIA_RE = re.compile(r"^(?:signage|stencils)/(\d+)/")

@require_http_methods(["GET", "HEAD"])
def serve_media_gated(request, path, document_root=None):
    """Servir ficheros de /media/ con autenticación y aislamiento por organización.

    - Normaliza el path ANTES de decidir y de servir (misma ruta para ambas cosas).
    - Solo sirve los árboles de _MEDIA_SERVABLE_PREFIXES (v1.156.0).
    - Anónimos sin path en _PUBLIC_MEDIA_RE → 404 (no 403).
    - Autenticados en asset de signage/stencils de OTRA organización → 404 (IDOR guard).
    - uploads/ (árbol plano, v1.159.0): solo si un registro de la org lo referencia.
    - MEDIA_ROOT resuelto en runtime (settings.MEDIA_ROOT o arg).
    """

Ubicación: core/views.py (línea ~163) Decorador: @require_http_methods(["GET", "HEAD"]) Parámetros:

Retorna: HttpResponse 200 con contenido si pasa los gates; Http404 si no (nunca 403 — no revela existencia).

Lógica (actualizada v1.156.0)

  1. Normalizar primero: norm = posixpath.normpath(path).lstrip("/"). Todo lo que sigue — allowlist de árbol, gate Y static_serve — usa norm, nunca el path crudo.
  2. Allowlist de árbol (v1.156.0): si norm no empieza por _MEDIA_SERVABLE_PREFIXES = ("uploads/", "signage/", "stencils/", "temp/") → Http404 inmediato, antes de mirar sesión ni organización.
  3. is_public_thumb = bool(_PUBLIC_MEDIA_RE.match(norm)).
  4. Si not request.user.is_authenticated: 404 salvo is_public_thumb.
  5. Si autenticado y no es thumb público: _ORG_SCOPED_MEDIA_RE.match(norm) extrae el <org_id> del path de signage o stencils; si no coincide con request.user.organization_id → 404 (cross-tenant).
  6. uploads/ (v1.159.0): árbol plano sin <org_id> en la ruta — el gate es org_references_upload(org_id, norm) (ver abajo). Si ningún registro de la org (ni un stencil global) referencia ese fichero → 404.
  7. static_serve(request, norm, document_root=...).
  8. Content-Disposition (v1.156.0): attachment si la extensión está en _MEDIA_ATTACHMENT_EXTS o no está en _MEDIA_INLINE_EXTS (allowlist, no blacklist — ver hardening abajo).

Fix v1.92.0 — bypass del gate por falta de normalización

Hasta v1.92.0 el gate (pasos 3-5) validaba sobre el path crudo de la URL, pero django.views.static.serve normaliza internamente (posixpath.normpath + lstrip("/")) antes de resolver el fichero en disco. Una petición como //signage/<orgB>/foo.jpg o a/../signage/<orgB>/foo.jpg no casaba el regex ^signage/ (el gate no saltaba, se dejaba pasar como “media sin org”), pero tras la normalización interna de Django el fichero servido SÍ pertenecía a <orgB> → lectura cross-tenant (IDOR). Al normalizar en serve_media_gated ANTES de decidir, y servir esa MISMA cadena normalizada, gate y fichero servido no pueden divergir.

Commit: c2df243 (#478, v1.92.0) — Tanda 2 de la Auditoría Suprema 2 (confinamiento de ficheros).

Hardening v1.156.0 — mega-auditoría B-04/B-06/B-07 (24-09-2026, PR #603)

El gate de organización (arriba) decide QUIÉN puede pedir un fichero; esta ronda cierra dos huecos sobre QUÉ árboles se sirven y CÓMO se sirven, más las dos vías de subida que alimentan /media con una extensión que hasta ahora se fiaba del nombre o del content_type que manda el cliente.

B-06 — árboles servibles (IDOR): cualquier usuario con sesión podía pedir /media/backups/<id de otra organización>/backup_<fecha>.zip (credenciales SNMP en claro dentro) o /media/restores/…. La Auditoría Suprema 2 lo dio por cerrado, pero el PR #478 (arriba) solo normalizó la ruta — no bloqueó el árbol. Nueva allowlist _MEDIA_SERVABLE_PREFIXES = ("uploads/", "signage/", "stencils/", "temp/"): norm que no empiece por uno de estos cuatro árboles → Http404 antes de llegar al gate de organización. backups/, restores/ y downloads/ quedan fuera (nada los enlazaba desde la UI; la copia manual sigue por su endpoint con permiso de racks, y el Agente por sus URLs propias). temp/ se queda porque el preview de importación de Visio pinta /media/temp/<sesión>/… — nombre de sesión aleatorio, pero cualquier autenticado puede verlo (límite aceptado, no cerrado).

B-04/B-07 — extensión vs. contenido real: _MEDIA_ATTACHMENT_EXTS (.svg, .html, .htm, .xml, .zip) era una lista NEGRA — todo lo que no estuviera ahí se servía en línea. Un fichero subido como x.xhtml se servía en línea como application/xhtml+xml (el middleware CSP solo protege text/html), y su script corría con la sesión de quien abriera el enlace. Se invierte a lista BLANCA: _MEDIA_INLINE_EXTS (imágenes raster, vídeo, audio, PDF) es lo único que se abre en línea; todo lo demás (.xhtml, .xht, .svgz, .js, .txt, .json…) se descarga con Content-Disposition: attachment, esté o no en la vieja lista negra.

Esto solo cierra el lado “cómo se sirve”. El lado “qué extensión queda grabada en disco” se cierra en las dos vías de subida que escriben bajo los árboles servibles:

Límite honesto: la comprobación de firma del logo son los primeros bytes, no una decodificación completa (un PNG truncado pasa — lo que importa es que no puede guardarse con una extensión que el navegador ejecute); los SVG de logo subidos ANTES de este cambio no se re-sanean.

Tests: tests/api/test_mega_T01.py (bloques B-06, B-04, B-07+B-32).

Hardening v1.159.0 — mega-auditoría ronda 4, flecos de T01 (25-09-2026, PR #607)

El límite que quedaba abierto en v1.156.0 (“uploads/ sigue siendo un árbol servible a cualquier organización autenticada”) se cierra: uploads/ no lleva <org_id> en la ruta, así que lo que lo acota al tenant ya no es “estás autenticado” sino qué registro de tu organización referencia ese fichero concreto.

org_references_upload(org_id, rel) (nueva, core/services/backup_service.py, junto a _media_relpath): dado un rel ya normalizado (uploads/...), comprueba con una consulta acotada por subcarpeta:

serve_media_gated llama a este helper cuando norm.startswith("uploads/"); sin match → 404, igual que el resto de gates (nunca 403).

_ORG_SCOPED_MEDIA_RE (antes _SIGNAGE_ORG_RE) se amplía a ^(?:signage|stencils)/(\d+)/: los stencils subidos o importados (stencils/<org_id>/<fichero>) llevan ahora la misma comprobación de organización que signage; los stencils globales de la plataforma viven en /static/, no bajo /media, así que no les afecta.

Efecto colateral limpiado: create_from_device (alta de stencil desde un equipo colocado) dejó de “heredar” silenciosamente una imagen de uploads/ que la organización destino no podía ver — el filtro que lo intentaba ya no protegía nada una vez que el gate real vive en serve_media_gated, así que se retiró.

Tests: tests/api/test_mega25_r4ficheros_uploads.py (191 líneas) — los tests existentes de /media ahora crean el registro que referencia el fichero antes de pedirlo (decisión de Edu: reflejar el caso real, no solo “usuario autenticado”).

Cache-Control privado — mega-auditoría ronda 4 (25-09-2026, PR #607)

Cloudflare cachea por extensión (.png, .exe…) y el origen no mandaba Cache-Control, así que el borde guardaba la respuesta por URL sin mirar la sesión: un 200 servido a un usuario con permiso podía llegar desde el borde a un anónimo, y un 404 servido a un anónimo, a los usuarios legítimos.

_private_cache(view, ok_max_age=None) (core/views.py), decorador:

Aplicación:

Tests: tests/api/test_mega25_r4ficheros_cache.py (134 líneas).

Excepción pública

Patrón: signage/<org_id>/thumbs/<filename> Caso de uso: Portal de cliente (share-token) pinta miniaturas 320px sin sesión Django. Verificación: Players SpinetiX acceden vía views_publish con FileResponse, no por /media.

Configuración

Patrón URL en config/urls.py (v1.159.0, con Cache-Control):

re_path(r"^media/(?P<path>.*)$", core_views.serve_media),

serve_media envuelve a serve_media_gated con _private_cache; llamar a serve_media_gated directamente (tests) sigue lanzando Http404 sin cabeceras de caché.

Motivo del cambio original: Anteriormente usaba django.views.static.serve directo (sin autenticación).

Tests

Véase tests/api/test_config_ia_diferidos.py::TestMediaGate:

Seguridad

Véase también

Subir