CreaRack-SL

Full Backup: Scoping de uploads por organización (fix security)

Resumen

El Full Backup (backup_full() + generate_backup()) empaquetaba toda la carpeta uploads/, lo que incluía uploads de todas las organizaciones (porque se guardan por tipo, sin prefijo de org). Resultado: (1) ZIP inflado (30-40 MB → 333 MB) + (2) fuga cross-tenant (cualquier admin se descargaba ficheros de otras empresas).

Fix: Nuevos helpers collect_org_upload_files() + org_upload_image_paths() en backup_service.py que empaquetan solo los ficheros referenciados por los registros de la org (stencils + blueprints), con normalización de rutas, dedup y guard anti-path-escape.

Problema detectado (s104 · Auditoría Etapa 3)

  • Reporte de Edu: backups de 30-40 MB pasaron a 333 MB al acumularse ficheros de varias organizaciones
  • Raíz: os.walk(MEDIA_ROOT/uploads) entero → todos los tenants en cada backup
  • Consecuencia: fuga de datos cross-tenant (uploads/blueprints/ y uploads/stencils/ sin prefijo de org)

Solución

Helpers en core/services/backup_service.py

_media_relpath(image_path)

  • Normaliza variantes de ruta almacenadas: /static/uploads/... → /media/uploads/... → uploads/...
  • Retorna la forma canónica uploads/... o None si no es upload
  • Rejectea paths fuera de uploads/ (logos, etc.)

collect_org_upload_files(image_paths)

  • Mapea image_path strings → tuplas (abs_path, arcname) de ficheros existentes
  • Clave: solo empaqueta los ficheros referenciados por registros de la org
  • Dedup + guard anti-path-escape (os.path.normpath + startswith check)
  • Arcname mantiene formato uploads/... que espera restore.py

org_upload_image_paths(backup_data)

  • Extrae todos los image_path de backup_data["stencils"] + backup_data["blueprints"]
  • Punto único de recolección de paths

Cambios en endpoints + servicios

racks/api/export/backup.py::backup_full(request)

  • Antes: os.walk(MEDIA_ROOT/uploads) → ZIP con todos los ficheros
  • Después: collect_org_upload_files(org_upload_image_paths(backup_data)) → ZIP solo con uploads referenciados

core/services/backup_service.py::generate_backup(org)

  • Antes: os.walk(MEDIA_ROOT/uploads) → ZIP con todos los ficheros
  • Después: collect_org_upload_files(org_upload_image_paths(backup_data)) → ZIP solo con uploads de la org

Tests

tests/api/test_backup.py — 5 nuevos tests:

  1. TestMediaRelpathHelper::test_normalizes_variants — _media_relpath() convierte /static/uploads/... ↔ /media/uploads/... ↔ uploads/... a forma canónica
  2. TestMediaRelpathHelper::test_rejects_non_uploads_and_empty — rechaza paths vacíos, no-uploads, etc.
  3. TestCollectOrgUploadFiles::test_only_existing_uploads_files — solo empaqueta ficheros que existen en disco
  4. TestCollectOrgUploadFiles::test_dedupes_and_blocks_path_escape — dedup + bloquea intento de ../../../etc/passwd
  5. TestBackupTenantScoping::test_backup_excludes_other_org_uploads — ⭐ generate_backup(orgA) excluye los uploads de orgB (cierre de fuga)

Suite de API: 258 verde (1 skip preexistente). Sin migraciones.

Verificación

  • Backup manual (Config > Exportar) ahora empaqueta solo uploads de tu org
  • Backup automático (cron en generate_backup()) también scoped por org
  • Restauración sigue funcionando igual (formato uploads/... conservado)
  • Tamaño de backup vuelve a rango esperado (30-40 MB)
  • Cross-tenant leak cerrada ✓

Véase también

  • [[entity—core—service—backup-service]]
  • [[entity—racks—endpoint—backup-full]]
  • [[entity—racks—model—stencil]]
  • [[entity—racks—model—blueprint]]
  • [[entity—core—model—organization]]