Servicio de seguridad en filesystem (safe-fs)
Descripción general
Helper centralizado de defensa contra vulnerabilidades de manejo de archivos en el módulo de importación/restauración de la librería de racks (dominio racks/api).
Ubicación: racks/utils/safe_fs.py
Líneas de código: 35 (3 funciones públicas + tests de cobertura)
Introducción: s104 (Auditoría Suprema — dominio racks)
Componentes públicos
is_valid_session_id(session_id: str) -> bool
Valida que el session_id sea un UUID4 bien formado (32 caracteres hex o formato estándar 8-4-4-4-12 con guiones).
Propósito: Evitar path traversal inyectando secuencias como ../.. en el identificador de sesión.
Firma:
def is_valid_session_id(session_id) -> bool:
"""True only for a well-formed uuid session id (no path separators, no '..'))."""
return isinstance(session_id, str) and bool(_SESSION_ID_RE.match(session_id))
Patrón regex: [0-9a-fA-F]{32} (hex compacto) o [0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} (RFC 4122).
Consumido por:
cleanup_session()(DELETE /api/racks/cleanup/session/{session_id})confirm_visio_import()(POST /api/racks/library/confirm-import)confirm_restore_backup()(POST /api/racks/library/confirm-restore)analyze_restore_backup()(POST /api/racks/library/analyze-restore)
is_within_directory(base_dir: str, target_path: str) -> bool
Resuelve rutas canonicalizadas (realpath) y verifica que el destino esté dentro del directorio base (no escape con .. o enlaces simbólicos).
Propósito: Guard contra Zip Slip (vulnerabilidad de archivos ZIP que escriben fuera del destino) y, desde v1.92.0, guard genérico de confinamiento para cualquier ruta controlada por el cliente (backups, imports).
Firma:
def is_within_directory(base_dir, target_path) -> bool:
"""True if *target_path* resolves to a location inside *base_dir*."""
base = os.path.realpath(base_dir)
target = os.path.realpath(target_path)
return target == base or target.startswith(base + os.sep)
Consumido por:
safe_extract_zip()(extracción validada de miembros ZIP)restore_full()(POST /api/racks/library/restore-full — extracción de backup completo)- v1.92.0 —
confirm_visio_import()(racks/api/library_files.py): confina el origen altemp_dirde la sesión (antes solo confinaba aMEDIA_ROOTentero, insuficiente — véase [[decision—20260713—visio-confirm-hardening]]). - v1.92.0 —
confirm_restore_backup()(racks/api/library_files.py): confinatemp_image(ruta del manifest del backup, subido por el usuario) altemp_dirantes deshutil.copy. - v1.92.0 —
_ensure_media_file()(racks/api/export/restore_domains.py): confina AMBOS extremos (dstysrc) aMEDIA_ROOT—rel_new/rel_oldsalen del JSON del backup, no confiable.
safe_extract_zip(zip_ref: zipfile.ZipFile, dest_dir: str) -> None
Itera sobre todos los miembros del archivo ZIP y rechaza cualquiera cuya ruta destino no esté dentro de dest_dir (según is_within_directory). Solo después de pasar todos los controles ejecuta zipf.extractall().
Propósito: Prevenir Zip Slip validando TODOS los members antes de extraer ninguno.
Firma:
def safe_extract_zip(zip_ref, dest_dir) -> None:
"""Extract *zip_ref* into *dest_dir*, rejecting members that escape it (Zip Slip)."""
for member in zip_ref.namelist():
if not is_within_directory(dest_dir, os.path.join(dest_dir, member)):
raise ValueError(f"Unsafe path in archive: {member!r}")
zip_ref.extractall(dest_dir)
Lanza: ValueError si algún member es peligroso.
Consumido por:
analyze_restore_backup()— pre-análisis de un backup ZIP antes de aplicarlo
Vulnerabilidades cerradas
| Bug | Función afectada | Guarda | Detalle |
|---|---|---|---|
Path traversal (../..) | cleanup_session() | is_valid_session_id() | session_id no validado se interpolaba en MEDIA_ROOT/temp/<session_id> y pasaba a shutil.rmtree() → borrado arbitrario de directorios. (s104) |
| Zip Slip | restore_full(), analyze_restore_backup() | safe_extract_zip() + is_within_directory() | Members de ZIP sin sanear podían escribir fuera del directorio de extracción. (s104) |
Path traversal cross-org, shutil.move destructivo | confirm_visio_import() | is_within_directory(temp_dir, …) | Guard previo (s220) confinaba a MEDIA_ROOT entero; un image_path apuntando a otra org resolvía dentro de ese prefijo y se movía (destruyendo el origen). Estrechado a temp_dir de la sesión. (v1.92.0, #478) |
| Path traversal, escritura fuera de sesión | confirm_restore_backup() | is_within_directory(temp_dir, …) | image_file del manifest.json subido por el usuario, sin confinar. (v1.92.0, #478) |
| Lectura/escritura arbitraria | _ensure_media_file() (restore_domains.py) | is_within_directory(media_root, …) en origen y destino | rel_new/rel_old del JSON del backup, sin confinar ninguno de los dos extremos. (v1.92.0, #478) |
Tests
tests/test_safe_fs.py(48 LOC):TestValidSessionId— acepta UUIDs válidos, rechaza traversal + garbageTestWithinDirectory— verifica escapes y confinamientoTestSafeExtractZip— extrae members seguros, rechaza traversal en ZIP
tests/racks/test_tanda2_file_confinement.py(v1.92.0, 97 LOC): confinamiento de_ensure_media_file(ataque + camino legítimo), gate admin de restore, logo servido por/media.
Véase también
- [[entity—racks—model—stencil]]
- [[entity—racks—model—rack]]
- [[entity—racks—model—config-backup]]
- [[entity—racks—endpoint—delete-stencil]]
- [[decision—20260713—visio-confirm-hardening]]
- [[entity—core—function—serve-media-gated]]
- [[concept—saas—multi-tenancy]]