CreaRack-SL

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 al temp_dir de la sesión (antes solo confinaba a MEDIA_ROOT entero, insuficiente — véase [[decision—20260713—visio-confirm-hardening]]).
  • v1.92.0 — confirm_restore_backup() (racks/api/library_files.py): confina temp_image (ruta del manifest del backup, subido por el usuario) al temp_dir antes de shutil.copy.
  • v1.92.0 — _ensure_media_file() (racks/api/export/restore_domains.py): confina AMBOS extremos (dst y src) a MEDIA_ROOT — rel_new/rel_old salen 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

BugFunción afectadaGuardaDetalle
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 Sliprestore_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 destructivoconfirm_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ónconfirm_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 destinorel_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 + garbage
    • TestWithinDirectory — verifica escapes y confinamiento
    • TestSafeExtractZip — 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]]