Membangun upload file yang aman di Flask: validasi tipe dan konten, menormalisasi nama file dengan secure_filename, membatasi ukuran, menyimpan upload terpisah dari kode aplikasi, serta praktik aman untuk avatar dan attachment.

Upload file adalah fitur yang paling sering disepelekan — dan salah satu yang paling berbahaya. File upload yang tidak aman bisa berarti: pengguna mengupload script berbahaya yang dieksekusi server, malware yang disebarkan ke pengunjung lain, atau disk penuh karena tidak ada batas ukuran. Bayangkan tanpa proteksi: upload.py memakai filename dari client secara langsung dan menyimpannya di folder statis — penyerang bisa mengupload shell.py yang dieksekusi server, atau nama ../../etc/cron.d/evil (path traversal) untuk menimpa file sistem. Episode 19 membangun upload yang aman: validasi, normalisasi nama, batas ukuran, dan penyimpanan yang benar.
Jangan percaya ekstensi — ekstensi mudah dipalsukan (photo.jpg bisa berisi script). Validasi berlapis:
from werkzeug.utils import secure_filename
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
ALLOWED_MIME = {"image/png", "image/jpeg", "image/gif", "image/webp"}
def allowed_file(file) -> bool:
if "." not in file.filename:
return False
ext = file.filename.rsplit(".", 1)[1].lower()
mime = file.mimetype
return ext in ALLOWED_EXTENSIONS and mime in ALLOWED_MIMEDua lapis: ekstensi dan MIME type. Keduanya bisa dipalsukan, jadi ini penyaring awal — bukan satu-satunya pertahanan. Untuk gambar, langkah tambahan yang kuat adalah memverifikasi isi file: imghdr/Pillow membaca header byte gambar. File yang diklaim PNG tetapi bukan gambar akan gagal didecode.
filename dari client tidak bisa dipercaya — bisa berisi path, karakter aneh, atau nama berbahaya. secure_filename() menormalisasinya:
from werkzeug.utils import secure_filename
original = "../../../../etc/evil.py" # nama dari client
safe = secure_filename(original) # "etc_evil.py"secure_filename membuang semua karakter berbahaya dan path traversal. Untuk keamanan maksimal, jangan simpan nama asli sama sekali — buat nama unik sendiri: random_name = f"{uuid.uuid4().hex}_{safe_name}" (dengan import secrets, uuid). Nama acak menghilangkan seluruh kelas masalah: bentrok nama, karakter aneh, dan kebocoran info dari nama file.
Rangkaian lengkap endpoint upload avatar:
import os
from uuid import uuid4
from flask import request, redirect, url_for
from werkzeug.utils import secure_filename
from app.extensions import db
from app.models.user import User
@main_bp.post("/profile/avatar")
@login_required
def upload_avatar():
file = request.files.get("avatar")
if file is None or file.filename == "":
flash("Pilih file dulu")
return redirect(url_for("main.profile"))
if not allowed_file(file):
flash("Tipe file tidak diizinkan")
return redirect(url_for("main.profile"))
# gambar divalidasi benar-benar gambar (decode via Pillow)
if not is_valid_image(file.stream):
flash("File bukan gambar valid")
return redirect(url_for("main.profile"))
safe_name = secure_filename(file.filename)
unique_name = f"{uuid4().hex}_{safe_name}"
upload_dir = current_app.config["UPLOAD_FOLDER"]
file.save(os.path.join(upload_dir, unique_name))
current_user.avatar = unique_name
db.session.commit()
return redirect(url_for("main.profile"))Catat urutan proteksi: ada file? tipe diizinkan? benar-benar gambar? baru simpan.
Tanpa batas, upload raksasa bisa membanjiri disk dan memori. Set MAX_CONTENT_LENGTH (episode 17):
class BaseConfig:
MAX_CONTENT_LENGTH = 8 * 1024 * 1024 # 8 MB
# folder upload terpisah dari static dan source code
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "/var/lib/flask/uploads")Request yang melebihi batas ditolak otomatis dengan 413 Request Entity Too Large. Untuk pesan yang ramah, tangkap exception RequestEntityTooLarge:
from werkzeug.exceptions import RequestEntityTooLarge
@app.errorhandler(RequestEntityTooLarge)
def too_large(e: Exception) -> tuple[dict, int]:
return api_error("file terlalu besar (maks 8 MB)", 413)Dua aturan yang sudah kita singgung di episode 7: simpan upload terpisah dari static/ dan source code, dan sajikan lewat endpoint khusus — bukan send_from_directory statis.
from flask import send_from_directory
@main_bp.get("/uploads/<filename>")
def uploaded_file(filename: str) -> Response:
resp = send_from_directory(
current_app.config["UPLOAD_FOLDER"], filename
)
resp.headers["Content-Security-Policy"] = "sandbox" # file tidak punya hak JS
resp.headers["X-Content-Type-Options"] = "nosniff"
return respContent-Security-Policy: sandbox mencegah file upload (yang mungkin berisi HTML/JS berbahaya) mengeksekusi script di browser. nosniff mencegah browser menebak tipe file. Header ini mengubah folder upload menjadi "area aman" meski ada file jahat lolos validasi.
Warning
Jangan pernah menyimpan upload di dalam folder static/ atau templates/. File di static/ disajikan mentah dengan jenis konten yang bisa dieksekusi — jika script .html/.svg jahat lolos, ia dieksekusi di domain kalian. Simpan di folder di luar root aplikasi (misal /var/lib/flask/uploads) dan sajikan via endpoint dengan header keamanan.
Untuk attachment (bukan hanya gambar), validasi sedikit berbeda — daftar ekstensi disesuaikan dan ukuran boleh lebih besar:
ALLOWED_ATTACHMENTS = {"pdf", "zip", "csv", "xlsx"}
@main_bp.post("/attachments")
@login_required
def upload_attachment():
file = request.files.get("file")
if file is None or file.filename == "":
return api_error("file wajib", 400)
ext = file.filename.rsplit(".", 1)[1].lower()
if ext not in ALLOWED_ATTACHMENTS:
return api_error("tipe file tidak diizinkan", 400)
unique_name = f"{uuid4().hex}_{secure_filename(file.filename)}"
file.save(os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name))
# simpan metadata di database untuk Content-Disposition yang benar
return api_ok({"filename": unique_name}), 201Untuk download dengan nama asli, endpoint serve tinggal memakai as_attachment=True dan download_name — sedangkan nama di disk tetap nama unik.
file.filename mentah: path traversal, karakter berbahaya — selalu secure_filename + nama unik.static/: file berbahaya dieksekusi di domain kalian.sandbox + nosniff meminimalkan kerusakan file lolos.Pada episode 19 ini, kalian telah membangun upload file yang aman.
Inti yang harus dibawa pulang:
secure_filename() + nama unik acak menghilangkan kelas masalah nama file.MAX_CONTENT_LENGTH membatasi ukuran; tangani 413 dengan ramah.Content-Security-Policy: sandbox dan nosniff.Di episode 20 selanjutnya, kita mengamankan transport: HTTPS & deployment security — force HTTPS, HSTS, cookie flags, dan konfigurasi Nginx + TLS di depan Flask. Sampai jumpa di episode 20!