Saltar al contenido principal

Excel

Ver en Git


Manejo seguro de Excel (generación y procesamiento)

Guía para replicar en otro repo el patrón que usamos para generar y procesar/validar archivos Excel (y CSV) de forma segura. Documenta las librerías, los wrappers, las validaciones y los puntos débiles a tener en cuenta.


1. Librerías usadas​

LibreríaVersión en el repoRol
pandas0.24.1Capa principal de lectura/escritura (read_excel, read_csv, to_excel, ExcelWriter).
openpyxl2.6.4Motor por defecto para escribir .xlsx. También se usa directo para estilos (Font, PatternFill) y Workbook/load_workbook.
xlrd1.0.0Motor de lectura de .xls (formato legacy) vía pandas.
python-magic (magic)—Detección del mimetype real a partir del contenido del archivo (no de la extensión).
werkzeug (secure_filename)—Saneado de nombres de archivo subidos.

Nota de compatibilidad: estas versiones están muy fijadas y son antiguas. En concreto, pd.ExcelWriter(..., options={"encoding": "utf-8"}) (ver AuraExcel) usa un argumento que fue eliminado en pandas moderno. Si portás esto a un pandas reciente, hay que quitar options= y ajustar to_excel (ya no acepta encoding ni engine por hoja).

Instalación equivalente en un repo nuevo (versiones modernas sugeridas):

pandas
openpyxl
xlrd # solo si necesitás leer .xls legacy
python-magic # en Windows: python-magic-bin

2. Generación de Excel: el wrapper AuraExcel​

Toda la generación pasa (o debería pasar) por un único wrapper para no repetir la configuración del writer. Vive en aurapi_src/handlers/aura_spreadsheet.py.

# -*- coding: utf-8 -*-
import io
import pandas as pd


class AuraExcel:
def __init__(self, filename, engine="openpyxl"):
self.filename = filename
self.excel_file = io.BytesIO() # se genera en memoria, nunca en disco
self.engine = engine
self.excel_writer = pd.ExcelWriter(self.excel_file, engine=self.engine)

def add_sheet(self, df, sheet_name="Sheet1", header=True, index=False, float_format="%.2f"):
df.to_excel(
self.excel_writer,
header=header,
index=index,
sheet_name=sheet_name,
float_format=float_format,
)

def close_excel(self):
self.excel_writer.close()
self.excel_file.seek(0)

def get_mem_file(self):
return self.excel_file

def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, exc_traceback):
self.close_excel()

Principios de la generación segura:

  • Todo en memoria (BytesIO): no se escriben temporales en disco, se evita dejar residuos y problemas de rutas.
  • Un solo motor (openpyxl) y float_format fijo para salida consistente.
  • Soporta with: al salir del bloque cierra el writer y rebobina el buffer solo.
  • Múltiples hojas: patrón habitual OK / ERROR para devolver resultados y errores en el mismo archivo (ver batch_new_fields.py).

Uso típico:

from aurapi_src.handlers.aura_spreadsheet import AuraExcel

with AuraExcel("resultado.xlsx") as excel:
excel.add_sheet(df_ok, "OK")
excel.add_sheet(df_errores, "ERROR")

mem_file = excel.get_mem_file() # BytesIO listo para send_file / subir a S3

Y para servirlo desde Flask:

return flask.send_file(
excel.get_mem_file(),
attachment_filename="resultado.xlsx",
as_attachment=True,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)

3. Procesamiento / validación de Excel de entrada​

Esta es la parte crítica de seguridad. El orden de validación importa: primero verificar el tipo real, después el contenido.

3.1. Validar por mimetype real, NO por extensión​

La extensión es controlada por el usuario y se puede falsificar. Usamos python-magic para leer los "magic bytes" del contenido. Definido en aurapi_src/handlers/mimetypes.py:

import magic

XLSX_MIMETYPES = [
'application/zip', # xlsx es un zip por dentro
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
]
CSV_MIMETYPES = ['application/csv', 'text/csv', 'text/plain']


def get_mimetype_from_buffer(file_buffer):
return magic.Magic(mime=True).from_buffer(file_buffer)

Y el patrón de carga (de batch_new_fields.py), que decide el reader según el mimetype:

def load_df(file_buffer):
m = get_mimetype_from_buffer(file_buffer.read())
file_buffer.seek(0) # importante: rebobinar tras leer
if m in XLSX_MIMETYPES:
df = pd.read_excel(file_buffer)
elif m in CSV_MIMETYPES:
df = pd.read_csv(file_buffer)
else:
raise AuraException('INVALID_FILE', 'invalid File')
...

3.2. Validar estructura y contenido del DataFrame​

Después de cargarlo, validar columnas, límites y celdas antes de tocar la base de datos:

COLUMNS = {'ID_USUARIO', 'CAMPO/FINCA', 'LOTE/PARCELA', 'WKT'}

if COLUMNS.difference(df.columns):
raise AuraException('MISS_COLS', 'missing columns') # columnas requeridas
elif df.empty:
raise AuraException('NO_DATA', 'no fields in file') # archivo vacío
elif len(df) > 250:
raise AuraException('LIMIT_250', 'limit of 250 fields exceeded') # límite de filas (DoS)
elif not pd.notna(df[COLUMNS]).all().all():
raise AuraException('EMPTY_CELLS', 'there are empty cells') # celdas vacías
elif not df['ID_USUARIO'].astype(str).str.isnumeric().all():
raise AuraException('INVALID USER ID', 'invalid user ids') # tipo de dato por columna

Checklist de validación de contenido:

  • Columnas requeridas presentes (set.difference).
  • No vacío (df.empty).
  • Límite de filas para evitar consumo excesivo de memoria/CPU (aquí > 250).
  • Sin celdas vacías en las columnas obligatorias.
  • Tipos por columna (ej. IDs numéricos, UUID con regex, fechas parseables).
  • Filtrado de filas inválidas en vez de fallar todo, cuando aplica (ver el filtro por UUID_PATTERN en spots.py).

3.3. Nombres de archivo: secure_filename​

Todo filename que venga del cliente pasa por secure_filename antes de usarlo para guardar, componer rutas o nombrar la descarga:

from werkzeug.utils import secure_filename
filename = secure_filename(uploaded.filename)

4. Cuando el Excel/CSV viene dentro de un ZIP​

Varios endpoints reciben un .zip que contiene el archivo de datos (CSV, XLSX, SHP, TXT, DAT).

Validaciones aplicadas (ver apis/layer/upload.py y modelos/models.py::CompressedFile):

  • La extensión debe ser .zip (filename.endswith(".zip")), y CompressedFile solo acepta _valid_extensions = (".zip",).
  • Se valida el contenido del zip antes de procesar (ej. GeoTIFF: exige que haya un único archivo y que sea .tif/.tiff; luego intenta abrirlo con Raster(...) y falla con InvalidFileError si no es válido).
  • El reader se detecta por extensión del archivo interno (_detect_reader), y el resultado se valida (geometrías permitidas, etc.).

⚠️ Punto débil a corregir al replicar: CompressedFile.extract() usa zip.extractall(self.dirpath) sin sanear los nombres de entrada del zip. Esto es vulnerable a Zip Slip / path traversal (una entrada como ../../etc/algo podría escribir fuera del directorio) y a zip bombs (no hay límite de tamaño descomprimido). Al portarlo, agregá:

  • Validar cada name del namelist(): rechazar rutas absolutas y las que contengan ...
  • Limitar tamaño total descomprimido y número de entradas.
  • Extraer a un directorio temporal aislado y validar que la ruta final resuelta quede dentro de él.

Esbozo de extracción segura:

import os, zipfile

def safe_extract(zip_path, dest_dir, max_total=200 * 1024 * 1024):
dest_dir = os.path.realpath(dest_dir)
total = 0
with zipfile.ZipFile(zip_path) as z:
for info in z.infolist():
target = os.path.realpath(os.path.join(dest_dir, info.filename))
if not target.startswith(dest_dir + os.sep):
raise ValueError("Zip slip detectado: %s" % info.filename)
total += info.file_size
if total > max_total:
raise ValueError("Zip demasiado grande al descomprimir")
z.extractall(dest_dir)

5. Lectura vía readers (patrón Reader)​

Para archivos de datos hay una jerarquía de readers en commons/readers.py con una interfaz común:

  • Reader.read() primero llama a valid() y, si pasa, a _read(); si no, lanza FileError.
  • ExcelReader._read() hace pd.read_excel(f) (depende de xlrd/openpyxl).
  • CSVReader hace dos pasadas: infiere tipos y luego relee con dtype optimizado; fuerza encoding="ISO-8859-1" y delimiter=";".
  • ReaderFactory.from_name(name) selecciona el reader por nombre.

Al replicar, mantener la separación validación (valid) → lectura (_read) para que ninguna lectura ocurra sin pasar por la validación.


6. Encoding​

  • Salida: los flujos de escritura fuerzan UTF-8 (en pandas moderno el default ya es UTF-8, quitar encoding=/options=).
  • Entrada CSV legacy: se lee con ISO-8859-1 y separador ; (formato regional). Ajustar según el origen real de los archivos.
  • Al sanear strings antes de serializar a JSON, decodificar bytes con errors='replace' (ver _sanitize_dataframe_strings en repository/layers/upload.py).

7. Checklist para replicar en otro repo​

Generación:

  • Un único wrapper tipo AuraExcel que escriba en BytesIO, motor openpyxl, soporte with.
  • float_format y nombres de hoja consistentes.
  • Servir con mimetype correcto y attachment_filename saneado.

Procesamiento:

  • Validar mimetype real con python-magic, no la extensión.
  • secure_filename en todo nombre recibido.
  • file_buffer.seek(0) tras leer para detectar el mimetype.
  • Validar columnas requeridas, no-vacío, límite de filas, celdas vacías y tipos por columna.
  • Filtrar filas inválidas cuando corresponda en vez de abortar todo.
  • Si viene en ZIP: exigir .zip, extracción con protección Zip Slip + límite de tamaño, y validar el archivo interno antes de procesarlo.
  • Mantener el patrón valid() → _read() para que no haya lectura sin validación previa.

Archivos de referencia en este repo​

  • Generación: aurapi_src/handlers/aura_spreadsheet.py
  • Mimetypes: aurapi_src/handlers/mimetypes.py
  • Validación de contenido (Excel/CSV → DataFrame): aurapi_src/repository/fields/batch_new_fields.py
  • Readers: commons/readers.py
  • Upload + ZIP: aurapi_src/apis/layer/upload.py, aurapi_src/repository/layers/upload.py
  • Manejo de ZIP: modelos/models.py (CompressedFile)
  • Procesamiento de sheet subida: aurapi_src/apis/spots.py