Excel
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ía | Versión en el repo | Rol |
|---|---|---|
pandas | 0.24.1 | Capa principal de lectura/escritura (read_excel, read_csv, to_excel, ExcelWriter). |
openpyxl | 2.6.4 | Motor por defecto para escribir .xlsx. También se usa directo para estilos (Font, PatternFill) y Workbook/load_workbook. |
xlrd | 1.0.0 | Motor 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"})(verAuraExcel) usa un argumento que fue eliminado en pandas moderno. Si portás esto a un pandas reciente, hay que quitaroptions=y ajustarto_excel(ya no aceptaencodingnienginepor 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) yfloat_formatfijo para salida consistente. - Soporta
with: al salir del bloque cierra el writer y rebobina el buffer solo. - Múltiples hojas: patrón habitual
OK/ERRORpara devolver resultados y errores en el mismo archivo (verbatch_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_PATTERNenspots.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")), yCompressedFilesolo 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 conRaster(...)y falla conInvalidFileErrorsi 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()usazip.extractall(self.dirpath)sin sanear los nombres de entrada del zip. Esto es vulnerable a Zip Slip / path traversal (una entrada como../../etc/algopodría escribir fuera del directorio) y a zip bombs (no hay límite de tamaño descomprimido). Al portarlo, agregá:
- Validar cada
namedelnamelist(): 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 avalid()y, si pasa, a_read(); si no, lanzaFileError.ExcelReader._read()hacepd.read_excel(f)(depende dexlrd/openpyxl).CSVReaderhace dos pasadas: infiere tipos y luego relee condtypeoptimizado; fuerzaencoding="ISO-8859-1"ydelimiter=";".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-1y 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_stringsenrepository/layers/upload.py).
7. Checklist para replicar en otro repo
Generación:
- Un único wrapper tipo
AuraExcelque escriba enBytesIO, motoropenpyxl, soportewith. -
float_formaty nombres de hoja consistentes. - Servir con mimetype correcto y
attachment_filenamesaneado.
Procesamiento:
- Validar mimetype real con
python-magic, no la extensión. -
secure_filenameen 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