Resumen ejecutivo
Sesión: s104 (2026-06-02)
Ámbito: Dominio racks — librería, importación/restauración, gestión de racks
Severidad: ALTA (6 bugs confirmados, 3 con vector de explotación directo)
Estado: ✅ CERRADO (21 tests verdes, parche mergeado)
Origen: Auditoría Suprema (96 agentes, 5.83M tokens) — hallazgos de seguridad a fondo en el producto
Bugs ALTA arreglados
1. Borrado/edición cross-tenant de stencils globales
Código afectado: racks/api/library.py — delete_stencil(), update_stencil(), move_stencil()
Bug:
# ANTES (incorrecto)
stencil = Stencil.objects.get(
Q(organization=org) | Q(organization__isnull=True), # ← Q(organization__isnull=True)
id=stencil_id,
)
Un tenant podía borrar/renombrar los stencils “globales del sistema” (aquellos con organization=None, compartidos por toda la plataforma). Bajo org=None (acceso de Agent JWT), operaba solo sobre los globales sin sesión humana.
Fix: Filtrar exclusivamente por organization=org + guarda if not org: return 400.
# DESPUÉS (correcto)
if not org:
return 400, {"message": "No Organization Context"}
stencil = Stencil.objects.get(id=stencil_id, organization=org)
Impacto: Aislamiento multi-tenant enforced a nivel de ORM.
Endpoints corregidos:
- DELETE /api/racks/stencils/{id}
- PUT /api/racks/stencils/{id}
- PUT /api/racks/stencils/{id}/move
2. Path traversal en cleanup_session + Zip Slip en restauración
Código afectado: racks/api/library.py (cleanup_session, confirm_visio_import, confirm_restore_backup, analyze_restore_backup) + racks/api/export/restore.py (restore_full)
Bug (Path Traversal):
# ANTES
@router.delete("/cleanup/session/{session_id}")
def cleanup_session(request, session_id: str):
shutil.rmtree(f"{MEDIA_ROOT}/temp/{session_id}") # ← session_id sin validar
Un atacante podía inyectar session_id = "../../etc" para borrar directorios arbitrarios del servidor.
Bug (Zip Slip):
# ANTES
with zipfile.ZipFile(zip_path, "r") as zipf:
zipf.extractall(temp_dir) # ← members sin sanear
# TAMBIÉN en restauración manual:
for name in zipf.namelist():
target = os.path.join(settings.MEDIA_ROOT, name) # ← no validado
with open(target, "wb") as f:
f.write(zipf.read(name))
Un ZIP malformado podía contener members como ../../../etc/passwd que escribirían fuera del destino (Zip Slip).
Fix: Nuevo módulo racks/utils/safe_fs.py con 3 helpers centralizados.
# DESPUÉS
if not is_valid_session_id(session_id):
return 400, {"message": "Invalid session_id"}
# + safe_extract_zip() en análisis/restauración
Impacto:
- Path traversal bloqueado por validación UUID4 estricta.
- Zip Slip prevenido por verificación pre-extracción de todos los members.
Endpoints corregidos:
- DELETE /api/racks/cleanup/session/{session_id}
- POST /api/racks/library/confirm-import (Visio)
- POST /api/racks/library/analyze-restore
- POST /api/racks/library/confirm-restore
- POST /api/racks/library/restore-full
3. Gating de rol roto (ignora superuser, crashea con AnonymousUser)
Código afectado: racks/api/library.py — move_stencil(), analyze_visio(), delete_library_category(), rename_library_category()
Bug:
# ANTES (incorrecto)
if request.user.role != "admin":
return 403, {"message": "Unauthorized"}
- Ignora superuser: Un usuario con
is_superuser=Truepero sin.role == "admin"se rechazaba (incorrecto). - Crashea con AnonymousUser: Bajo Agent JWT,
request.useresAnonymousUsery no tiene.role→AttributeError500.
Fix: Usar la función centralizada is_admin(request.user) de core.api, que maneja is_superuser, TemporaryAccess, y AnonymousUser.
# DESPUÉS (correcto)
if not is_admin(request.user):
return 403, {"message": "Unauthorized - Admin role required"}
Impacto: Gating de rol unificado y robusto en toda la app.
Funciones/endpoints corregidos:
move_stencil()— PUT /api/racks/stencils/{id}/moveanalyze_visio()— POST /api/racks/library/analyze-visiodelete_library_category()— DELETE /api/racks/library/category/deleterename_library_category()— PUT /api/racks/library/category/rename
4. delete_rack hacía hard delete, saltaba papelera
Código afectado: racks/api/racks.py — delete_rack()
Bug:
# ANTES
rack.delete() # Hard delete → fila desaparece de la BD
El endpoint DELETE /api/racks/{id} borraba definitivamente el rack, saltándose el sistema de soft-delete (papelera/trash). Un borrado accidental era irreversible.
Fix: Usar rack.soft_delete() (método de modelo que setea deleted_at).
# DESPUÉS
rack.soft_delete() # Soft delete → fila persiste, marked with deleted_at
Impacto: Borrados recuperables via papelera (modelo Rack ya tenía soporte con campo deleted_at y método soft_delete(), solo no se estaba usando).
Cambio de comportamiento en tests:
- Test
test_delete_rackactualizado: ahora verificarack.deleted_at is not Noneen vez de que la fila no existe.
5. Gating de rol roto en gestión de categorías (mismo patrón)
Subsum en el bug #3 (gating con request.user.role != "admin"). Funciones:
delete_library_category()rename_library_category()
Ambas reemplazan el chequeo con is_admin(request.user).
6. PUT /api/racks/bulk-update: endpoint roto (inexistente field)
Código afectado: racks/api/racks.py — endpoint bulk_update_racks() + schema BulkUpdateRacksSchema
Bug:
# ANTES
class BulkUpdateRacksSchema(Schema):
rack_ids: list[int]
category: str # ← Rack.category NO EXISTE en el modelo
@router.put("/bulk-update")
def bulk_update_racks(request, payload):
racks = Rack.objects.filter(id__in=payload.rack_ids)
racks.update(category=payload.category) # ← FieldError 500 en cada llamada
El endpoint intentaba escribir Rack.category, un campo inexistente en el modelo. Resultado: FieldError 500 en cada llamada (roto al 100%).
Fix: Remover el endpoint y el schema. La agrupación de racks ya se cubre con PUT /api/racks/groups/assign.
# DESPUÉS
# Removido completamente.
# Nota en el código:
# NOTE: el endpoint PUT /api/racks/bulk-update se retiro (s104):
# escribia Rack.category — un campo que NO existe en el modelo Rack — por lo que
# daba FieldError 500 en cada llamada (roto al 100%) y no tenia consumidores en el
# frontend. La agrupacion de racks se hace via PUT /api/racks/groups/assign.
Impacto:
- Remueve un endpoint que nunca funcionó.
- Sin consumidores en el frontend (verificado).
- Funcionalidad real (agrupar racks) ya está en otro endpoint.
Contexto de la auditoría
Auditoría Suprema (s104): Revisión a fondo del producto con 96 agentes + motor afinado. 13 ALTA confirmados en el dominio racks:
| ID | Área | Severidad | Estado | Notas |
|---|---|---|---|---|
| s104.1 | Stencils globales | ALTA | ✅ CERRADO | Cross-tenant delete/edit |
| s104.2 | Path traversal | ALTA | ✅ CERRADO | cleanup_session UUID validation |
| s104.3 | Zip Slip | ALTA | ✅ CERRADO | safe_extract_zip + is_within_directory |
| s104.4 | Rol gating | ALTA | ✅ CERRADO | is_admin() central |
| s104.5 | Soft-delete | ALTA | ✅ CERRADO | delete_rack → soft_delete() |
| s104.6 | Endpoint roto | ALTA | ✅ CERRADO | Remover bulk-update |
| s104.7 | Backup async | ALTA | ⏳ BACKLOG (Etapa 3) | Atomicidad de restore |
| s104.8 | Decompression bomb | ALTA | ⏳ BACKLOG (Etapa 3) | Límite de tamaño en ZIP |
| s104.9 | u_position validation | ALTA | ⏳ BACKLOG (Etapa 3) | Range check Device.u_position |
| … (13 total) |
Este commit: Cierra los 6 de mayor gravedad/esfuerzo S (mayor impacto vs. menor trabajo de parche).
Cobertura de tests
Nuevos tests (21 verde):
tests/api/test_racks_security.py (92 LOC)
-
TestStencilGlobalIsolation(3 tests):- No se puede borrar stencil global desde un tenant
- Se puede borrar stencil propio
- No se puede actualizar stencil global desde un tenant
-
TestDeleteRackSoftDelete(1 test):- delete_rack es soft-delete (deleted_at set)
-
TestPathTraversalGuard(1 test):- cleanup_session rechaza session_id inválido (no-UUID)
-
TestBulkUpdateRemoved(1 test):- PUT /api/racks/bulk-update ya no responde 200
-
TestCategoryRoleGating(1 test):- DELETE /api/racks/library/category/delete rechaza viewer_user (no admin)
tests/test_safe_fs.py (48 LOC)
-
TestValidSessionId(7 tests):- Acepta uuid4 con guiones y hex compacto
- Rechaza traversal (
../..), garbage, None, strings vacías
-
TestWithinDirectory(2 tests):- Verifica confinamiento de ruta
- Rechaza escapes
-
TestSafeExtractZip(2 tests):- Extrae members seguros
- Rechaza member con traversal en ZIP
Cambios en modelos/schema
| Tipo | Antes | Después | Razón |
|---|---|---|---|
| Schema | BulkUpdateRacksSchema | (removido) | Endpoint roto (FieldError) |
| Endpoint | PUT /api/racks/bulk-update | (removido) | Sin consumidores, roto |
| Modelo | Rack.delete() | Rack.soft_delete() | Papelera en delete_rack |
| Helper | (inexistente) | racks/utils/safe_fs.py | Path traversal + Zip Slip guard |
Notas de implementación
Deuda técnica preexistente
El archivo racks/api/library.py ya contenía 519 LOC lógicas (umbral Regla 5: >500). Este parche suma +1 LOC neto. Se bloquea pre-commit bajo [--no-verify autorizado por Edu].
Deuda anotada: Troceo de library.py + sentinel.py en backlog de modularización.
Patrones aplicables a otros dominios
Los 6 fixes en racks son aplicables a otros módulos:
- Multi-tenancy: Auditar otros filtros
Q(org) | Q(org__isnull=True). - Rol gating: Reemplazar todos los
request.user.role != "admin"conis_admin(). - Soft-delete: Auditar otros endpoints DELETE que podrían saltarse la papelera.
- Path traversal: Extender
safe_fs.pya otros módulos de import.
CHANGELOG / RELEASE_NOTES
Documentado en:
- CHANGELOG.md — 25 líneas (Fixed + Removed + Added + Notes)
- RELEASE_NOTES.md — 20 líneas (Lo que cambia, Por qué, Impacto al usuario)
Véase también
- [[entity—racks—service—safe-fs]]
- [[entity—racks—model—stencil]]
- [[entity—racks—model—rack]]
- [[entity—racks—model—config-backup]]
- [[concept—saas—multi-tenancy]]