Subida de archivos con Flask: validación y almacenamiento privado
Para subir un archivo con Flask, envía un formulario multipart, valida la solicitud y guarda el flujo subido. Este tutorial te proporciona un formulario local para el navegador que acepta archivos JPEG, PNG y PDF y los almacena fuera del directorio estático público. También comprobarás los bytes guardados y verás qué ocurre cuando se rechaza una subida.
Configura tu entorno de Flask
Usa Linux con Bash, Python 3.14, soporte para entornos virtuales y libmagic instalado. Las instrucciones de instalación de python-magic explican cómo instalar la biblioteca del sistema; instalar solo el paquete de Python no la instala. El ejemplo usa Flask 3.1.3, Flask-WTF 1.3.0 y python-magic 0.4.27.
Desde un directorio donde guardes proyectos, ejecuta:
mkdir flask-upload-demo &&
cd flask-upload-demo &&
python3 -m venv .venv &&
.venv/bin/python -m pip install Flask==3.1.3 Flask-WTF==1.3.0 python-magic==0.4.27 &&
mkdir templates
Los comandos se detienen si el proyecto ya existe o si falla algún paso de la configuración.
Continúa solo cuando se completen correctamente, con la terminal aún en
flask-upload-demo. Crea allí los siguientes dos archivos.
Crea el formulario de subida de archivos
Guarda lo siguiente como templates/upload.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Upload a file</title>
</head>
<body>
<h1>Upload a file</h1>
<p>Choose a JPEG, PNG, or PDF. The whole request must fit within 16 MiB.</p>
{% for message in get_flashed_messages() %}
<p role="status">{{ message }}</p>
{% endfor %}
{% if error %}<p role="alert">{{ error }}</p>{% endif %}
<form method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{{ form.file.label }} {{ form.file }}
{% for errors in form.errors.values() %}
{% for message in errors %}
<p role="alert">{{ message }}</p>
{% endfor %}
{% endfor %}
<button type="submit">Upload</button>
</form>
</body>
</html>
La codificación multipart transporta los bytes del archivo. hidden_tag() genera el
token CSRF que Flask-WTF valida con la sesión del navegador. novalidate te permite
probar la respuesta del servidor cuando falta un archivo, en lugar de que el navegador impida
el envío primero. La clase del formulario está en app.py, más abajo.
Implementa la gestión de subidas de archivos
Guarda esta aplicación completa como app.py:
import os
import tempfile
from pathlib import Path
import magic
from flask import Flask, flash, redirect, render_template, request, url_for
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from werkzeug.exceptions import RequestEntityTooLarge
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
upload_directory = Path(app.instance_path) / 'uploads'
upload_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
ALLOWED_TYPES = {
'image/jpeg': ('jpg', 'jpeg'),
'image/png': ('png',),
'application/pdf': ('pdf',),
}
class UploadForm(FlaskForm):
file = FileField('File', validators=[
FileRequired('Choose a file.'),
FileAllowed(['jpg', 'jpeg', 'png', 'pdf'], 'Choose a JPEG, PNG, or PDF.'),
])
def save_upload(file):
extension = file.filename.rsplit('.', 1)[-1].lower()
detected_type = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
extensions = ALLOWED_TYPES.get(detected_type)
if extensions is None or extension not in extensions:
raise ValueError('The file contents do not match an allowed file type and extension.')
# The client filename never becomes a storage path.
target = tempfile.NamedTemporaryFile(
dir=upload_directory, prefix='upload-', suffix='.' + extensions[0], delete=False,
)
try:
with target:
file.save(target)
except OSError:
Path(target.name).unlink(missing_ok=True)
raise
return Path(target.name).name
@app.errorhandler(RequestEntityTooLarge)
def too_large(error):
# Do not parse the rejected request again while rendering its error page.
form = UploadForm(formdata=None)
return render_template(
'upload.html', form=form,
error='Upload request is too large. Choose a smaller file.',
), 413
@app.route('/', methods=['GET', 'POST'])
def upload():
form = UploadForm()
if request.method == 'GET':
return render_template('upload.html', form=form)
if not form.validate_on_submit():
return render_template('upload.html', form=form), 400
try:
filename = save_upload(form.file.data)
except ValueError:
return render_template(
'upload.html', form=form,
error='The file contents do not match an allowed file type and extension.',
), 400
except (OSError, magic.MagicException) as error:
app.logger.error('Upload failed (%s)', type(error).__name__)
return render_template(
'upload.html', form=form,
error='The file could not be saved. Please try again.',
), 500
flash(f'Saved as {filename}.')
return redirect(url_for('upload'), code=303)
Los validadores de archivos de Flask-WTF comprueban que se haya seleccionado un archivo y que su extensión esté permitida. Luego, la función auxiliar usa libmagic para inspeccionar los primeros 2.048 bytes, siguiendo las recomendaciones de python-magic, y vuelve al inicio del flujo antes de guardarlo. Sin ese retorno al inicio, el archivo almacenado perdería los bytes ya leídos. El tipo MIME declarado por el navegador no se usa para tomar esta decisión.
Estas comprobaciones rechazan discrepancias evidentes, como un archivo de texto renombrado a
.png. No decodifican el archivo por completo, no detectan todos los daños
ni demuestran que su contenido sea inofensivo. Mantén las subidas privadas hasta que se complete
correctamente cualquier análisis de seguridad o procesamiento específico del formato que tu
aplicación necesite.
NamedTemporaryFile crea un nombre nuevo para cada subida aceptada, con permisos de
archivo restringidos al usuario que lo crea en Linux. Incluso dos subidas llamadas
photo.png se guardan en archivos separados. A pesar del nombre de la función,
delete=False conserva las subidas exitosas en instance/uploads hasta que
las elimines. Un error de escritura o cierre capturado activa la eliminación del archivo parcial
de ese intento; aun así, un proceso terminado de forma forzada o un sistema de archivos que
rechace la eliminación pueden dejar un archivo sin borrar.
Ejecuta el formulario y verifica una subida
En el mismo directorio del proyecto, genera un secreto de sesión local e inicia Flask:
FLASK_SECRET_KEY="$(.venv/bin/python -c 'import secrets; print(secrets.token_hex(32))')" &&
export FLASK_SECRET_KEY &&
.venv/bin/python -m flask --app app run --host 127.0.0.1 --port 5000
Abre http://127.0.0.1:5000/, selecciona un archivo JPEG, PNG o PDF pequeño con
File y pulsa Upload.
La página devuelta muestra Saved as seguido del
nombre de archivo generado. Un POST exitoso redirige a un GET, por lo que actualizar esa página
de resultados no vuelve a enviar el archivo. Si vuelves a enviar el formulario deliberadamente,
se crea otra copia.
En otra terminal, entra en el directorio del proyecto y compara tu archivo original con el guardado. Reemplaza ambas rutas del ejemplo con las correspondientes y usa el nombre generado que aparece en la página:
cmp -- '/path/to/photo.png' 'instance/uploads/upload-example.png'
La ausencia de salida y un estado de salida cero indican que los bytes coinciden. El archivo
guardado está fuera del directorio static de Flask, y esta aplicación no tiene
una ruta de descarga. Mantén el directorio de la instancia fuera de cualquier asignación de rutas
de un servidor web público.
Detén Flask con Ctrl+C. Para reiniciarlo, ejecuta de nuevo el comando de inicio desde el directorio del proyecto. El comando genera un secreto nuevo, así que recarga cualquier formulario abierto antes de enviarlo. Si el puerto 5000 está ocupado, elige un puerto libre en el comando y en la URL del navegador. Este servidor de desarrollo se mantiene en la interfaz de loopback; la aplicación no tiene inicio de sesión, cupo de almacenamiento ni política de eliminación automática.
Gestión de errores y validación
Prueba estos casos mientras observas el estado del POST en el panel Red del navegador:
| Entrada o condición | Respuesta esperada | Resultado almacenado |
|---|---|---|
| Envía el formulario sin seleccionar un archivo | 400 y Choose a file. | Ningún archivo nuevo |
Selecciona un archivo .txt | 400 y Choose a JPEG, PNG, or PDF. | Ningún archivo nuevo |
Renombra un archivo de texto sin formato a .png o envía un .png vacío | 400 y un mensaje de discrepancia entre contenido y tipo | Ningún archivo nuevo |
| Selecciona un archivo de 16 MiB o más | 413 y Upload request is too large. Choose a smaller file. | Ningún archivo nuevo |
Elimina el elemento input oculto csrf_token en las herramientas de desarrollo del navegador antes de enviar el formulario | 400 y The CSRF token is missing. | Ningún archivo nuevo |
| Sube un archivo cuando el directorio de almacenamiento no permita la escritura | 500 y The file could not be saved. Please try again. | Ningún archivo nuevo si falló la creación |
El límite de solicitud incluye los campos y delimitadores multipart, por lo que el archivo más grande aceptado es ligeramente menor que 16 MiB. Flask también limita el tamaño y la cantidad de campos multipart. Su documentación sobre subidas señala que algunas subidas al servidor de desarrollo pueden terminar con un restablecimiento de la conexión en lugar de mostrar una respuesta 413.
Para los errores CSRF, recarga la página y selecciona el archivo de nuevo. Mantén el secreto estable cuando ejecutes varios procesos de la aplicación: todos deben usar las mismas firmas de sesión y de token. La protección CSRF valida el token de sesión del formulario; no autentica a quien sube el archivo. Los fallos de almacenamiento registran solo el tipo de excepción y no muestran en la página las rutas locales ni los detalles de diagnóstico.
Si necesitas una API de subida de archivos con Flask
Este ejemplo devuelve HTML y requiere la cookie de sesión y el token CSRF del navegador. Por eso, una solicitud multipart básica con cURL no supera la validación del formulario. Para una API independiente, decide quién puede subir archivos, cómo se autentican los clientes y qué respuestas JSON manejan antes de reutilizar la lógica de validación y almacenamiento. Una API autenticada mediante cookies sigue necesitando protección CSRF; cambiar el formato de respuesta no elimina ese requisito.
Gestión de subidas de archivos grandes
Si más adelante colocas la aplicación detrás de Nginx, su
client_max_body_size
puede rechazar la solicitud antes de que llegue a Flask. Alinea los límites del proxy y de la
aplicación, y gestiona los errores en ambas capas. Aumentar solo el límite del proxy no permite
superar el límite de 16 MiB de Flask.
Para subidas reanudables, usa un protocolo como el protocolo tus con un servidor y un cliente compatibles. El formulario de este ejemplo envía una sola solicitud multipart; dividir un archivo en fragmentos desde el navegador requeriría una implementación diferente del servidor para llevar el seguimiento de los fragmentos y ensamblarlos.
El procesamiento en segundo plano es un paso independiente posterior a la recepción de un archivo. Un proceso de trabajo puede procesar los bytes almacenados, pero no hace que este formulario permita reanudar subidas ni evita el límite de solicitud. Este ejemplo local termina cuando se guarda el archivo. Si el procesamiento se vuelve lento más adelante, la guía de Celery de Flask explica la configuración adicional del proceso de trabajo y del intermediario de mensajes; también necesitarás una política para las tareas fallidas y las subidas conservadas.
Antes de hacer públicas las subidas
Elige una política de retención para instance/uploads antes de acumular datos reales de
usuarios. Añade autenticación, autorización y cupos por usuario, así como las comprobaciones de
contenido necesarias para tus formatos de archivo, antes de aceptar tráfico público o permitir
descargas. Esas decisiones corresponden a la aplicación que usa los archivos subidos; el formulario
local te ofrece un lugar donde probarlas.
