Belajar Flask - Upload, File & Content Security
Episode 19 of 27

Belajar Flask - Upload, File & Content Security

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.

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

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.

Memvalidasi Tipe File

Jangan percaya ekstensi — ekstensi mudah dipalsukan (photo.jpg bisa berisi script). Validasi berlapis:

PythonValidasi ekstensi dan tipe MIME
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_MIME

Dua 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.

Normalisasi Nama File

filename dari client tidak bisa dipercaya — bisa berisi path, karakter aneh, atau nama berbahaya. secure_filename() menormalisasinya:

PythonNormalisasi nama file
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.

Setup Endpoint Upload

Rangkaian lengkap endpoint upload avatar:

Pythonroutes/main.py - 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.

Membatasi Ukuran File

Tanpa batas, upload raksasa bisa membanjiri disk dan memori. Set MAX_CONTENT_LENGTH (episode 17):

PythonBatasi ukuran upload
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:

PythonHandler 413
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)

Menyimpan dan Menyajikan Upload dengan Aman

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.

PythonEndpoint serve upload
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 resp

Content-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.

Contoh Endpoint Upload Attachment

Untuk attachment (bukan hanya gambar), validasi sedikit berbeda — daftar ekstensi disesuaikan dan ukuran boleh lebih besar:

PythonUpload attachment
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}), 201

Untuk download dengan nama asli, endpoint serve tinggal memakai as_attachment=True dan download_name — sedangkan nama di disk tetap nama unik.

Common Pitfalls Upload & File

  • Menyimpan file.filename mentah: path traversal, karakter berbahaya — selalu secure_filename + nama unik.
  • Validasi hanya ekstensi: MIME juga bisa dipalsukan — validasi isi (Pillow) untuk gambar.
  • Upload di folder static/: file berbahaya dieksekusi di domain kalian.
  • Serving upload tanpa header keamanan: sandbox + nosniff meminimalkan kerusakan file lolos.

Penutup

Pada episode 19 ini, kalian telah membangun upload file yang aman.

Inti yang harus dibawa pulang:

  • Validasi berlapis: ada file → ekstensi & MIME → isi file valid (Pillow).
  • secure_filename() + nama unik acak menghilangkan kelas masalah nama file.
  • MAX_CONTENT_LENGTH membatasi ukuran; tangani 413 dengan ramah.
  • Sajikan via endpoint khusus dengan 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!

Belajar Flask - Upload, File & Content Security | Belajar Flask