Implementación de subidas seguras de archivos en FastAPI: patrones prácticos para UploadFile, límites de tamaño, escaneo de virus, almacenamiento compatible con S3 y URLs prefirmadas
Resumen (primero la visión general)
- Para subidas de archivos en FastAPI, usar
UploadFilees eficiente en memoria y proporciona una base práctica sólida. - Las claves de seguridad son: límites de tamaño, validación de MIME/contenido (no solo extensiones), separación de destinos de almacenamiento, saneamiento de nombres de archivo, controles de autorización y registro/auditoría.
- En producción, es más estable que el servidor de API no sirva continuamente los cuerpos de archivos. Un patrón común es almacenar archivos en un almacenamiento compatible con S3 y que la API se centre en emitir URLs prefirmadas.
- El procesamiento pesado, como la generación de miniaturas o la conversión de PDF, debe delegarse a trabajos en segundo plano después de la subida para mantener rápidas las respuestas.
- Para pruebas, un conjunto mínimo que cubra éxito, exceso de tamaño, MIME inválido, no autorizado y URL prefirmada expirada reduce incidentes.
A quién beneficia esto
- Desarrolladores en solitario / aprendices: quieres imágenes de perfil o adjuntos, pero no sabes hasta dónde llegar con medidas de seguridad.
- Equipos pequeños: aumentan los adjuntos de administración y subidas de CSV, y aparecen preocupaciones como límites de tamaño y suplantación de extensiones.
- Equipos SaaS: en operaciones multi-instancia, la entrega de archivos se vuelve pesada; quieres separación de almacenamiento, URLs prefirmadas y buenos registros de auditoría.
Notas de accesibilidad
- Los encabezados son granulares y los pasos están numerados, lo que facilita encontrar la información.
- Los términos técnicos se explican brevemente la primera vez que aparecen, y se usa un vocabulario consistente para reducir confusión.
- El código se divide en bloques pequeños, con comentarios mínimos.
- El nivel objetivo es aproximadamente AA.
1. Patrones comunes de fallo: primero conoce las “formas peligrosas”
Las subidas de archivos son fáciles de hacer “funcionar”, pero muchos tropiezos pueden causar incidentes reales:
- Aceptar archivos sin límite de tamaño, agotando memoria o disco
- Confiar solo en extensiones y permitir archivos cuyo contenido difiere (p. ej., un ejecutable disfrazado de
.jpg) - Guardar el nombre de archivo proporcionado por el usuario tal cual, provocando path traversal, mojibake o sobrescrituras no deseadas
- Permitir que el servidor de API también gestione la entrega de archivos, ahogando CPU/red en picos
- Autorización vaga (quién puede ver qué archivo), llevando a exposición de adjuntos de otros usuarios
El objetivo de este artículo es construir una “plantilla” práctica que evite estos problemas uno por uno.
2. Subida mínima: por qué usar UploadFile
En FastAPI, UploadFile es el valor por defecto más fácil y seguro. Puedes aceptar bytes, pero archivos grandes presionan la memoria.
# app/routers/uploads.py
from fastapi import APIRouter, UploadFile, File
router = APIRouter(prefix="/uploads", tags=["uploads"])
@router.post("")
async def upload(file: UploadFile = File(...)):
return {"filename": file.filename, "content_type": file.content_type}
Dos notas importantes:
- No confíes en
file.filename(sirve como referencia de visualización, pero no lo uses como nombre almacenado) - Tampoco confíes plenamente en
file.content_type(el cliente puede falsearlo)
Para estar seguro, importan la validación y una estrategia de almacenamiento.
3. Límites de tamaño: defensa en dos capas (app + proxy)
Los límites de tamaño son más efectivos cuando rechazas solicitudes demasiado grandes en la “entrada”, antes de que lleguen a tu app.
3.1 Limitar en un reverse proxy (Nginx, etc.)
Si usas Nginx en producción, configura primero client_max_body_size. Esto evita que solicitudes enormes lleguen a la app.
3.2 Limitar en la app (detenerse mientras se lee)
Las configuraciones del proxy pueden variar por entorno, así que imponer límites en la app es un buen respaldo. Lee el stream de UploadFile en trozos; si supera el umbral, devuelve un error.
# app/services/upload_validator.py
from fastapi import HTTPException, status, UploadFile
MAX_BYTES = 10 * 1024 * 1024 # 10MB
async def enforce_size_limit(file: UploadFile) -> bytes:
total = 0
chunks: list[bytes] = []
while True:
chunk = await file.read(1024 * 1024) # 1MB cada vez
if not chunk:
break
total += len(chunk)
if total > MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="file too large",
)
chunks.append(chunk)
# Este ejemplo junta al final para mostrar el concepto.
# En producción, se recomienda escribir en streaming a disco/almacenamiento.
return b"".join(chunks)
Este ejemplo ilustra el concepto. En producción, un diseño de guardado en streaming (sección siguiente) es más seguro.
4. Saneamiento de nombres de archivo: genera el nombre almacenado del lado del servidor
No uses un nombre de archivo proporcionado por el usuario como nombre almacenado. Genera un ID único en el servidor.
- Nombre de visualización: el nombre del usuario (guárdalo en BD si hace falta)
- Nombre real almacenado: clave segura generada con UUID, etc.
Por ejemplo, usa una clave de objeto como uploads/{user_id}/{uuid}.{ext}.
# app/services/upload_naming.py
import uuid
from pathlib import Path
ALLOWED_EXT = {"png", "jpg", "jpeg", "pdf"}
def safe_object_key(user_id: int, original_filename: str) -> str:
ext = Path(original_filename).suffix.lower().lstrip(".")
if ext not in ALLOWED_EXT:
ext = "bin"
uid = uuid.uuid4().hex
return f"uploads/{user_id}/{uid}.{ext}"
La idea clave: trata las extensiones como “pistas” y valida el contenido real después.
5. Validación de tipo: comprueba el contenido real, no solo extensiones
Como base de seguridad, no decidas permitir/denegar solo por extensión.
5.1 Comprobación mínima de MIME
Comprobar content_type es útil como primera barrera, pero puede falsearse. Úsalo como “primer checkpoint”.
# app/services/content_type.py
from fastapi import HTTPException, status, UploadFile
ALLOWED_CT = {
"image/png",
"image/jpeg",
"application/pdf",
}
def enforce_content_type(file: UploadFile) -> None:
if file.content_type not in ALLOWED_CT:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="unsupported content type",
)
5.2 Inspección simple del contenido (ligera)
Idealmente verificarías firmas (magic numbers) con librerías robustas, pero la elección depende del entorno. Aquí va un ejemplo mínimo “hecho a mano”:
# app/services/magic_check.py
from fastapi import HTTPException, status
def looks_like_png(head: bytes) -> bool:
return head.startswith(b"\x89PNG\r\n\x1a\n")
def looks_like_jpeg(head: bytes) -> bool:
return head.startswith(b"\xff\xd8\xff")
def looks_like_pdf(head: bytes) -> bool:
return head.startswith(b"%PDF")
def enforce_magic(head: bytes, content_type: str) -> None:
ok = False
if content_type == "image/png":
ok = looks_like_png(head)
elif content_type == "image/jpeg":
ok = looks_like_jpeg(head)
elif content_type == "application/pdf":
ok = looks_like_pdf(head)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="file content does not match content-type",
)
En producción, muchos equipos usan librerías de detección más robustas. En cualquier caso, lo importante es la postura: “no confiar solo en extensiones”.
6. Estrategia de almacenamiento: elegir entre almacenamiento local y almacenamiento compatible con S3
6.1 Almacenamiento local (aprendizaje / pequeña escala)
Si ejecutas un solo servidor y los archivos son pequeños, el almacenamiento local puede funcionar. Pero con múltiples instancias, compartir se vuelve difícil.
# app/services/local_storage.py
from pathlib import Path
from fastapi import UploadFile
BASE = Path("./data")
async def save_to_local(path: str, file: UploadFile) -> None:
full = BASE / path
full.parent.mkdir(parents=True, exist_ok=True)
with full.open("wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
6.2 Almacenamiento compatible con S3 (el estándar de producción)
En producción, separar almacenamiento del servidor de API mejora la estabilidad. Guarda en un storage compatible con S3, y deja que la API se centre en “aceptar subidas” y “emitir URLs de acceso”.
Hay dos enfoques típicos:
- Subida vía API: la API recibe el archivo y lo reenvía al storage
- Subida directa: la API emite una URL prefirmada; el navegador sube directamente al storage vía PUT (menos carga)
A medida que escala la operación, el #2 tiende a ser más fuerte. La siguiente sección introduce patrones de URLs prefirmadas.
7. URLs prefirmadas: saca al servidor de API de la ruta de entrega
Una URL prefirmada es una “URL con acceso restringido y tiempo limitado”.
- URL de subida (PUT)
- URL de descarga (GET)
Separarlas hace más clara la autorización.
7.1 Diseño de alto nivel
POST /files/presign-upload: devuelve la URL de destino de subida y la claveGET /files/{file_id}/download: devuelve una URL de descarga- Guardar en BD:
file_id,owner_id,object_key,content_type,size,original_name, etc.
Ejemplo de modelo de respuesta en FastAPI (solo la forma):
from pydantic import BaseModel
class PresignUploadResponse(BaseModel):
file_id: str
object_key: str
upload_url: str
expires_in: int
7.2 Checklist de seguridad
- Mantén corta la expiración (unos minutos a ~15 minutos)
- Al emitir URLs de descarga, siempre verifica propiedad/permisos
- Si es posible, incluye condiciones como
Content-Typey tamaño (restricciones del lado del storage)
Los detalles del SDK varían entre nubes, así que centrarse en el diseño del API y los puntos de control suele ser el mejor primer paso.
8. Escaneo de virus y saneamiento: un punto medio práctico
Si aceptas archivos, la probabilidad de recibir un archivo malicioso nunca es cero.
- Cuanto más expuesto públicamente esté tu servicio, más valioso se vuelve el escaneo/sandboxing.
- Pero intentar hacerlo perfecto desde el día uno puede ser pesado—un despliegue por fases es realista.
Plan escalonado:
- Límites de tamaño/tipo + nombres almacenados seguros (obligatorio)
- Para imágenes, re-codificar (no servir subidas “tal cual”)
- Ejecutar escaneo (p. ej., ClamAV) de forma asíncrona
- Usar un bucket de cuarentena; mover solo archivos “OK” al bucket principal
El truco clave: no publicar inmediatamente tras la subida. Para adjuntos públicos, un flujo de cuarentena aumenta materialmente la seguridad.
9. Post-procesamiento (miniaturas, etc.): delega a trabajos en segundo plano
Generación de miniaturas, vistas previas de PDF, OCR y tareas similares conviene mantenerlas fuera de respuestas síncronas.
Flujo de ejemplo:
- Aceptar subida → guardar
status=processingen BD - Encolar
file_id - Worker procesa → actualiza a
status=ready, guarda claves derivadas - Frontend cambia la UI según
status
Esto encaja bien con patrones como Celery/Redis.
10. Ejemplo de implementación de API: la subida segura mínima
Aquí hay un ejemplo mínimo de guardado local que combina las piezas anteriores. En producción, cámbialo por una implementación de storage.
# app/routers/uploads.py
from fastapi import APIRouter, UploadFile, File, Depends
from app.services.content_type import enforce_content_type
from app.services.magic_check import enforce_magic
from app.services.upload_naming import safe_object_key
from app.services.local_storage import save_to_local
router = APIRouter(prefix="/uploads", tags=["uploads"])
def get_current_user_id() -> int:
# En realidad, deriva de JWT, etc.
return 1
@router.post("")
async def upload_file(
file: UploadFile = File(...),
user_id: int = Depends(get_current_user_id),
):
enforce_content_type(file)
head = await file.read(16)
enforce_magic(head, file.content_type)
# Como leímos la cabecera, debemos escribirla durante el guardado
object_key = safe_object_key(user_id, file.filename)
# Escribir la cabecera y luego streamear el resto
# local_storage.save_to_local lee de file.read(), así que continúa desde el puntero actual
# Aquí escribimos la cabecera por separado
from pathlib import Path
base = Path("./data")
full = base / object_key
full.parent.mkdir(parents=True, exist_ok=True)
with full.open("wb") as f:
f.write(head)
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
return {
"file_id": object_key, # Idealmente devuelve un ID generado por la BD
"content_type": file.content_type,
}
Lo que este ejemplo ya protege:
- Validación de
content_typecomo primer filtro - Comprobaciones simples de firma (magic number)
- Nombres almacenados generados por el servidor
- Escrituras en streaming
Añade límites de tamaño, propiedad en BD, descargas autorizadas y URLs prefirmadas para llegar a un diseño de producción.
11. Diseño de descargas: la autorización es lo más importante
Las descargas suelen ser más sensibles que las subidas—“quién puede verlo” es crítico.
- Buscar
owner_iden la BD porfile_id - Verificar que el usuario actual coincide con
owner_ido es admin - Para storage compatible con S3, emitir una URL GET prefirmada y devolverla
- Para almacenamiento local, devolver
FileResponse—pero ten en cuenta el aumento de carga del servidor de API
Si “cualquiera que conozca el file ID puede descargar”, los incidentes son probables. Protégelo con tests.
12. Set mínimo de pruebas: cinco tests que previenen incidentes
Las pruebas iniciales más efectivas:
- Éxito: tipo permitido + tamaño pequeño → 200/201
- Exceso de tamaño: supera el límite → 413
- MIME inválido:
content-typeno permitido → 415 - Suplantación:
content-typepermitido pero firma no coincide → 400 - Autorización: no se puede descargar el
file_idde otra persona → 403/404
Pocas pruebas pueden dar una protección fuerte si la intención es clara.
13. Hoja de ruta de adopción (pasos pequeños están bien)
- Construir subidas mínimas usando
UploadFile - Añadir límites de tamaño, nombres almacenados seguros y restricciones de content-type
- Añadir inspección de contenido (simple está bien; asegurar que la suplantación no pase)
- Gestionar metadatos + propiedad en BD y construir descargas autorizadas
- Pasar a almacenamiento compatible con S3 y dividir subida/descarga con URLs prefirmadas
- Mover procesamiento de imágenes/PDF a trabajos en segundo plano (cuarentena, miniaturas)
- Añadir logs de auditoría, alertas y métricas de capacidad/tasas de fallo
Referencias
- FastAPI
- Starlette (la base de FastAPI)
- AWS (útil para pensar el diseño)
- Perspectiva de seguridad (útil como checklists)
Conclusión
- Las subidas de archivos en FastAPI funcionan bien alrededor de
UploadFile, mejorando la eficiencia de memoria y la extensibilidad. - En la práctica, lo esencial es: límites de tamaño, inspección de contenido, nombres almacenados seguros y autorización basada en propietario.
- En producción, evita que el servidor de API sea un distribuidor de cuerpos de archivo; separa el almacenamiento y usa URLs prefirmadas para operaciones estables.
- Descarga el post-procesamiento pesado (miniaturas/escaneo) a trabajos en segundo plano para que la API se mantenga ligera y las operaciones sean fluidas.
Buenos candidatos para artículos de seguimiento son “diseño multi-tenant (límites de tenant y autorización)” y “diseño de logs de auditoría (registrar quién hizo qué)”. Si quieres, puedo continuar con esos temas.

