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/...oNonesi no es upload - Rejectea paths fuera de uploads/ (logos, etc.)
collect_org_upload_files(image_paths)
- Mapea
image_pathstrings → 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 esperarestore.py
org_upload_image_paths(backup_data)
- Extrae todos los
image_pathdebackup_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:
TestMediaRelpathHelper::test_normalizes_variants—_media_relpath()convierte/static/uploads/...↔/media/uploads/...↔uploads/...a forma canónicaTestMediaRelpathHelper::test_rejects_non_uploads_and_empty— rechaza paths vacíos, no-uploads, etc.TestCollectOrgUploadFiles::test_only_existing_uploads_files— solo empaqueta ficheros que existen en discoTestCollectOrgUploadFiles::test_dedupes_and_blocks_path_escape— dedup + bloquea intento de../../../etc/passwdTestBackupTenantScoping::test_backup_excludes_other_org_uploads— ⭐generate_backup(orgA)excluye los uploads deorgB(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]]