CreaRack-SL

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:

  • request — objeto request Django (is_authenticated, organization_id)
  • path — ruta relativa a MEDIA_ROOT tal como llega en la URL (sin normalizar)
  • document_root — raíz de ficheros (por defecto settings.MEDIA_ROOT)

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:

  • POST /api/racks/import-image (racks/api/export/network_config.py::import_image): ya no usa os.path.splitext(image.name) (el nombre lo manda el cliente); pasa por _validate_stencil_ext (racks/api/library.py, la misma allowlist que la librería de stencils) y devuelve 400 si no encaja.
  • POST /api/settings/company/logo (core/api/settings.py::upload_company_logo): la extensión sale de la FIRMA de los primeros 8 bytes (_LOGO_SIGNATURES: PNG \x89PNG\r\n\x1a\n, JPEG \xff\xd8\xff), no del nombre ni del content_type declarado. Un SVG se acepta solo por nombre .svg y pasa por racks.utils.svg_sanitizer.sanitize_svg_file, igual que los stencils; si falla el saneado, 400 y se borra el fichero. También deja rastro en el registro del sistema (log_action, categoría SYSTEM, settings.company.update / settings.company.logo — B-32).

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:

  • uploads/logos/… → el logo_path de la Organization con ese id.
  • uploads/blueprints/… → un Blueprint de la org cuyo image_path coincide.
  • uploads/stencils/… → un Stencil de la org o global (organization NULL) cuyo image_path coincide; si ninguno, una segunda consulta mira si algún Device colocado en un rack de la org guarda esa misma ruta en su copia de model_data ("original_image") — así un equipo cuyo stencil se borró después sigue viendo su imagen. Esta segunda vía es solo para uploads/stencils/, nunca planos ni logos (model_data lo escribe el cliente, no es de fiar para nada más).
  • Cualquier otro prefijo bajo uploads/ (o sin org_id/rel) → False.

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:

  • 200/304 con ok_max_age: Cache-Control: private, max-age=<ok_max_age> + Vary: Cookie (Cloudflare no guarda private; el navegador del propio usuario, sí).
  • Todo lo demás (404, 405, sin ok_max_age): private, no-store (add_never_cache_headers, igual que @never_cache en signage). El Http404 se convierte en respuesta (page_not_found) para poder llevarle la cabecera.

Aplicación:

  • serve_media = _private_cache(serve_media_gated, ok_max_age=3600) — nueva entrada serve_media en config/urls.py (reemplaza a serve_media_gated directo). Los 200/304 de /media se guardan 1 hora en el navegador del propio usuario (private, no en el borde): el editor de racks pedía cada imagen en cada carga con no-store. # UNVERIFIED: 1 h por criterio, no medido — no hay medición del peor patrón de uso del editor.
  • download_agent y download_agent_package (instalador y paquete del Agente): _private_cache sin ok_max_age → siempre private, no-store (ficheros grandes que cambian con cada versión; no interesa cachearlos ni en el navegador).

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:

  • test_anonymous_gets_404 — anónimo → 404
  • test_authenticated_user_can_download — autenticado → 200
  • test_signage_thumbnail_stays_public — signage/1/thumbs/logo.jpg → 200 (anónimo)
  • test_thumbnail_exception_does_not_cover_originals — signage/1/originals/video.mp4 → 404 (anónimo)
  • v1.92.0: 2 tests nuevos de traversal (//signage/<orgB>/…, a/../signage/<orgB>/… → 404 para usuario de otra org).
  • v1.156.0: tests/api/test_mega_T01.py — árbol backups/ fuera (B-06), .xhtml ya no se sirve en línea (B-04), import-image y logo validan la extensión real (B-04/B-07).
  • v1.159.0: tests/api/test_mega25_r4ficheros_uploads.py — gate por referencia en uploads/, stencils/<org_id>/ como signage; tests/api/test_mega25_r4ficheros_cache.py — Cache-Control en 200/404/405 de /media y descargas del Agente.

Seguridad

  • No revelar existencia: 404 en lugar de 403 (no comunica “acceso denegado”).
  • Multi-tenant por path: _ORG_SCOPED_MEDIA_RE valida el <org_id> embebido en el path de signage/stencils contra request.user.organization_id.
  • Multi-tenant por referencia (v1.159.0): uploads/ no lleva <org_id> en el path — el aislamiento lo da org_references_upload, que exige que ALGÚN registro de la organización (o un stencil global) apunte a ese fichero exacto.
  • Gate y servido sobre la MISMA cadena normalizada (v1.92.0): invariante que cierra la clase de bug “gate sobre crudo, servido sobre normalizado”.
  • Árbol servible como allowlist, no blacklist (v1.156.0): _MEDIA_SERVABLE_PREFIXES decide qué existe para /media, no qué se excluye.
  • Content-Disposition como allowlist, no blacklist (v1.156.0): mismo principio aplicado a qué se abre en línea (_MEDIA_INLINE_EXTS).
  • La extensión persistida en disco no es de fiar si viene del nombre o content_type del cliente: import_image y upload_company_logo (v1.156.0) validan el contenido real.
  • Cache-Control privado en todas las respuestas (v1.159.0): ni Cloudflare ni ningún proxy comparte cachea /media ni las descargas del Agente — private siempre, con max-age solo para los 200/304 de /media.
  • Runtime MEDIA_ROOT: permite que tests usen directorio temporal sin congelar en urls.py.

Véase también

  • [[decision—20260611—auditoria-suprema-etapa-3-cierre-findings-m1-b6-e]]
  • [[entity—racks—service—safe-fs]]
  • [[concept—saas—multi-tenancy]]
  • [[concept—security—authentication-first]]
  • [[entity—core—service—backup-service]] — org_references_upload vive junto a _media_relpath, que ya acotaba las copias de seguridad a lo referenciado por la org.