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 defectosettings.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)
- Normalizar primero:
norm = posixpath.normpath(path).lstrip("/"). Todo lo que sigue — allowlist de árbol, gate Ystatic_serve— usanorm, nunca elpathcrudo. - Allowlist de árbol (v1.156.0): si
normno empieza por_MEDIA_SERVABLE_PREFIXES = ("uploads/", "signage/", "stencils/", "temp/")→Http404inmediato, antes de mirar sesión ni organización. is_public_thumb = bool(_PUBLIC_MEDIA_RE.match(norm)).- Si
not request.user.is_authenticated: 404 salvois_public_thumb. - 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 conrequest.user.organization_id→ 404 (cross-tenant). - uploads/ (v1.159.0): árbol plano sin
<org_id>en la ruta — el gate esorg_references_upload(org_id, norm)(ver abajo). Si ningún registro de la org (ni un stencil global) referencia ese fichero → 404. static_serve(request, norm, document_root=...).- Content-Disposition (v1.156.0):
attachmentsi la extensión está en_MEDIA_ATTACHMENT_EXTSo 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 usaos.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 delcontent_typedeclarado. Un SVG se acepta solo por nombre.svgy pasa porracks.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/…→ ellogo_pathde laOrganizationcon ese id.uploads/blueprints/…→ unBlueprintde la org cuyoimage_pathcoincide.uploads/stencils/…→ unStencilde la org o global (organizationNULL) cuyoimage_pathcoincide; si ninguno, una segunda consulta mira si algúnDevicecolocado en un rack de la org guarda esa misma ruta en su copia demodel_data("original_image") — así un equipo cuyo stencil se borró después sigue viendo su imagen. Esta segunda vía es solo parauploads/stencils/, nunca planos ni logos (model_datalo escribe el cliente, no es de fiar para nada más).- Cualquier otro prefijo bajo
uploads/(o sinorg_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 guardaprivate; 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_cacheen signage). ElHttp404se 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 entradaserve_mediaenconfig/urls.py(reemplaza aserve_media_gateddirecto). Los 200/304 de/mediase 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 conno-store.# UNVERIFIED: 1 h por criterio, no medido — no hay medición del peor patrón de uso del editor.download_agentydownload_agent_package(instalador y paquete del Agente):_private_cachesinok_max_age→ siempreprivate, 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 → 404test_authenticated_user_can_download— autenticado → 200test_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— árbolbackups/fuera (B-06),.xhtmlya 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 enuploads/,stencils/<org_id>/como signage;tests/api/test_mega25_r4ficheros_cache.py— Cache-Control en 200/404/405 de/mediay descargas del Agente.
Seguridad
- No revelar existencia: 404 en lugar de 403 (no comunica “acceso denegado”).
- Multi-tenant por path:
_ORG_SCOPED_MEDIA_REvalida el<org_id>embebido en el path de signage/stencils contrarequest.user.organization_id. - Multi-tenant por referencia (v1.159.0):
uploads/no lleva<org_id>en el path — el aislamiento lo daorg_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_PREFIXESdecide 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_imageyupload_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
/mediani las descargas del Agente —privatesiempre, conmax-agesolo 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_uploadvive junto a_media_relpath, que ya acotaba las copias de seguridad a lo referenciado por la org.