Auditoría Suprema 2 · Cola racks: librería de stencils, backup/restore, plantillas y rastro de auditoría
Cuándo
04-09-2026 · commit 17879889 (PR #500, v1.109.0). Cola MEDIA/BAJA del dominio racks en la Auditoría Suprema 2 (task #286): de los 29 hallazgos MEDIA del catálogo auditoria-suprema-2/racks.json, 14 se cierran en este commit, 8 ya estaban resueltos por la Tanda 7 genérica, y 6 quedan diferidos con motivo explícito en el CHANGELOG. Los 7 hallazgos ALTA del mismo catálogo NO se tocan en este PR — ver Preventivos futuros. Implementación en Opus (worktree propio, en paralelo con los PRs de network y signage); revisión de Fable 5.1 antes de mergear. mypy limpio en racks + network/api/port_connections.py (48 ficheros).
Síntomas visibles
PUT /api/racks/{id}/devicesdevolvía al navegador elmanagement_configcifrado del equipo (#8).apply_templateno validaba la altura del rack destino ni invalidaba la caché de roll-ups: una plantilla de 42U sobre un rack de 12U dejaba equipos en U40 (#10).- Cada autosave del editor descifraba con Fernet TODOS los
management_configde la organización para construir la caché de IPs, aunque el payload no trajera ninguna IP (#12). - El envío a la papelera desde el editor — el camino más destructivo del dominio — no dejaba autor ni rastro de auditoría (#17).
- El restore extraía
uploads/**del ZIP tal cual sobre un namespace plano común a todas las organizaciones: podía sobrescribir un fichero vivo de otro tenant (#22). - Ni el restore completo ni el de librería tenían tope de subida, ni la descompresión del ZIP tenía techo (zip-bomb) (#23).
import_imageconstruía la ruta de disco con lacategoryque manda el cliente: una cadena"../../.."escapaba deMEDIA_ROOT(#24).import_imageguardaba unimage_pathde/static/mientras escribía el fichero enMEDIA_ROOT: el stencil nacía con la miniatura rota y la API respondía 200 igualmente (#25).- El backup exportaba
parent_rack_idpero el restore nunca lo aplicaba: la jerarquía de racks se perdía y la verificación decíacomplete(#26). - Nadie podaba
MEDIA_ROOT/temp/: cada sesión de Visio o de librería abandonada dejaba el ZIP descomprimido en el volumen para siempre (#27). - El backup completo, el restore y las mutaciones de librería no dejaban ninguna fila en el audit log (#28).
create_from_device,import_visioycleanup_sessionmutaban la librería del tenant sin exigirracks:admin, a diferencia de sus hermanos (#29).create_custom_stencilcargaba el fichero entero en memoria (ContentFile(read())) sin validar tipo ni tamaño (#31).- La foto diaria de integridad se calculaba para TODAS las organizaciones, inactivas incluidas, y ninguna tarea purgaba las filas acumuladas (#34).
- Los campos de cableado no tenían
max_lengthen el schema Ninja pero sí en la columna: un texto largo devolvía 500 en vez de 400 (#36). - (Sin numerar, hallado al escribir los tests) las rutas estáticas
/stencils/custom,/create_from_imagey/create_from_deviceestaban registradas DESPUÉS de/stencils/{stencil_id}: Django Ninja resuelve por orden yPOST /stencils/customrespondía 405 — endpoint muerto.
Causa raíz
- Rutas de fichero construidas con input del cliente sin confinar contra
MEDIA_ROOTen todos los caminos de escritura (import_image,uploads/**del restore). - Mutadores de librería añadidos sin heredar el gate
racks:adminde sus hermanos — cada endpoint nuevo repetía la comprobación a mano. - Validaciones de negocio duplicadas en vez de reutilizar el helper compartido:
apply_templatereimplementaba la copia de equipos sin las comprobaciones quecopy_devices_fromya tenía. - Ningún camino que acepta un ZIP subido por el usuario tenía tope de tamaño ni de descompresión.
- Acciones destructivas o de bajo nivel (papelera, backup, restore, borrado de categoría) sin paso por
log_rack_action: el audit log solo cubría los mutadores más antiguos del dominio. - Orden de registro de rutas en el router: Django Ninja resuelve por posición y una ruta dinámica registrada antes que las estáticas las sombrea en silencio.
- Tareas periódicas sin filtro de organizaciones activas ni política de retención.
Fix aplicado
Commit 17879889 (PR #500):
racks/schemas.DeviceSaveResultSchemadeja de exponermanagement_config; la caché de IPs del autosave (racks/services/racks.py) solo se construye si el payload trae IP.apply_templatedelega encopy_devices_from(target, template, "replace"): valida altura, invalida roll-ups y la respuesta traecopied/skipped.racks/utils/safe_fs.py:MAX_UNCOMPRESSED_BYTESensafe_extract_zip;restore.pyañadeMAX_RESTORE_UPLOAD_BYTES(2 GB) yMAX_BACKUP_DATA_BYTES(512 MB);library_files.pytopa el restore de librería a 200 MB;create_custom_stencila 25 MB.restore.py: un miembro deuploads/**cuyo destino ya existe no se sobrescribe.network_config.py(import_image): categoría normalizada +is_within_directory, SVG saneado,image_pathapunta a/media.restore_inventory.py: segunda pasada dentro de la misma transacción que aplicaparent_rack_idcon elrack_mapcompleto.racks/tasks.py: nueva task periódicacleanup_media_temp(03:50, umbral 24 h); snapshots de integridad filtranis_activey purgan a 90 días.log_rack_actionañadido entrash_device/bulk_trash,backup.start,backup.download,restore.full,library.backup,library.restore,library.category.delete.require_perm(request, "racks", "admin")añadido encreate_from_device,import_visio,cleanup_session.racks/api/library.py: rutas estáticas de stencils reordenadas antes que/stencils/{stencil_id}.network/api/port_connections.py:max_lengthen los campos de cableado del schema Ninja.- 20 tests nuevos en
tests/racks/test_cola_auditoria_racks.py; regresión del área en verde (281 + 36 tests).
Lecciones
- Un endpoint nuevo que muta la misma entidad que sus hermanos hereda su gate de permiso por convención, no por defecto — sin un decorador o mixin compartido, cada alta puede olvidarlo.
- Cualquier camino que acepta un ZIP de usuario necesita tope de subida Y de descompresión desde el primer commit: el zip-bomb no es un caso límite, es el caso por defecto a defender.
- Un router que resuelve por orden de registro (Django Ninja) exige que las rutas estáticas vayan primero; un test de integración por ruta lo habría atrapado antes de producción.
- La papelera y el rastro de auditoría no son gratis: cada acción destructiva nueva necesita su propia línea de
log_rack_action, no hereda la del endpoint que la inspiró.
Preventivos futuros
- 7 hallazgos ALTA de
racks.jsonsiguen sin tocar, ninguno cubierto por este PR: path traversal en el restore de librería (library_files.confirm_restore_backup, el campofiledel manifest copia cualquier fichero del servidor); backups nocturnos de TODOS los tenants descargables por cualquier usuario autenticado (/media/backups/<org_id>/...);confirm_visio_importpuede robar o destruir ficheros de OTRAS organizaciones;_ensure_media_filedel restore lee/escribe sin confinar origen ni destino; cuatro caminos que persisten SVG en la librería sin sanitizar; el radar de integridad sirve el inventario de red (IP, MAC, nº de serie) sin exigirnetwork:view; yclone_rackduplica elmanagement_config— el clon comparte IP de gestión con el original y, con la papelera nueva, retirar el clon borra el original. - 6 hallazgos MEDIA diferidos con motivo (detalle en CHANGELOG v1.109.0): #18 borrados que se saltan la papelera (
copy_fromreplace, vaciar papelera de racks), #20 IP de gestión en columna propia, #21 contrato delta del editor, #30restore_previewsíncrono, #33/overviewsin caché, #35port_keysin validar contra los puertos del equipo. - El re-mapeo de
uploads/por tenant (#22) queda a medias: se evita la sobrescritura, pero no se migran las rutas ya guardadas enStencil.image_path/Blueprint.image_path— decisión pendiente de Edu.
Véase también
- [[incident—20260831—auditoria-suprema-2-tanda1-monitoring-racks]]
- [[incident—20260602—auditoria-suprema-racks]]
- [[incident—20260904—auditoria-suprema-2-cola-monitoring-a-sondas-y-targets]]
- [[entity—racks—service—apply-full-restore-from-zip]]
- [[entity—racks—service—safe-fs]]
- [[entity—racks—service—run-full-backup]]
- [[entity—racks—model—stencil]]
- [[feature—racks—full-backup-tenant-scoping]]
- [[feature—racks—validacion-entrada-library-r7p1a]]
- [[crearack—racks—trash-and-restore]]