Fitur unggah file merupakan salah satu vektor serangan paling kritis pada aplikasi web. Mengandalkan validasi berbasis ekstensi file (file.name.endswith('.jpg')) atau MIME type yang dikirimkan oleh browser client (file.content_type) tidak memberikan jaminan keamanan. Nilai-nilai tersebut sepenuhnya berada di bawah kendali penyerang dan mudah dimanipulasi.

Artikel ini membahas langkah implementasi untuk mengeraskan (hardening) penanganan file upload pada Django melalui verifikasi magic bytes, sanitasi nama file, isolasi media storage, serta konfigurasi HTTP security headers.

Vektor Serangan: Mengapa Validasi Ekstensi Gagal

Pemeriksaan ekstensi file standar rentan terhadap beberapa metode eksploitasi spesifik:

  • Eksekusi File Arbitrer: Penyerang mengunggah skrip berbahaya (misal: webshell PHP/Python) dengan ekstensi ganda atau manipulasi ekstensi jika web server dikonfigurasi untuk mengeksekusi skrip di dalam folder publik.
  • Polyglot Files: Berkas yang valid menurut dua format berbeda sekaligus. Contohnya, file gambar GIF atau JPEG yang valid di bagian header (misal: GIF89a), namun memuat payload skrip di segmen metadata atau trailing data.
  • Stored XSS via SVG: Berkas SVG berbasis XML. Jika pengguna mengunggah SVG yang berisi elemen <script> dan server menyajikannya dengan header Content-Type: image/svg+xml secara inline, skrip akan dieksekusi di browser korban dalam konteks origin aplikasi.

Verifikasi MIME Type Nyata Menggunakan Magic Bytes

Solusi yang tepat untuk memvalidasi tipe berkas adalah memeriksa magic bytes (tanda tangan biner pada header berkas). Pustaka python-magic menyediakan binding Python untuk pustaka sistem libmagic.

Instal dependensi sistem dan pustaka Python terlebih dahulu:

# Ubuntu / Debian
sudo apt-get install libmagic1

# Python package
pip install python-magic

Berikut implementasi validator reusable di Django. Validator membaca 2048 byte pertama untuk mengidentifikasi tipe biner, lalu mengembalikan penunjuk posisi stream (seek(0)) agar pembacaan file berikutnya oleh Django storage backend tidak corrupt.

# validators.py
import magic
from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible

@deconstructible
class MagicMimeValidator:
    def __init__(self, allowed_mimes):
        self.allowed_mimes = allowed_mimes

    def __call__(self, file_obj):
        # Baca sample header file
        initial_pos = file_obj.tell()
        file_obj.seek(0)
        header_chunk = file_obj.read(2048)
        file_obj.seek(initial_pos)

        # Deteksi MIME type riil berdasarkan magic bytes
        mime_type = magic.from_buffer(header_chunk, mime=True)

        if mime_type not in self.allowed_mimes:
            raise ValidationError(
                f"Tipe file tidak diizinkan: {mime_type}. "
                f"File yang diterima: {', '.join(self.allowed_mimes)}"
            )

Sanitasi Filename dan Penanganan Direktori Simpan

Jangan pernah menyimpan file menggunakan nama mentah dari input pengguna (uploaded_file.name). Hal ini memicu celah path traversal (misal: ../../etc/cron.d/job) atau benturan nama berkas. Gunakan UUID untuk mengaburkan nama asli dan petakan ekstensi berdasarkan MIME type yang telah divalidasi.

# models.py
import os
import uuid
from django.db import models
from .validators import MagicMimeValidator

def secure_upload_path(instance, filename):
    ext = os.path.splitext(filename)[1].lower()
    # Fallback: jika ekstensi tidak lazim, tentukan dari kontrol ketat
    new_filename = f"{uuid.uuid4().hex}{ext}"
    return os.path.join("documents/", new_filename)

class DocumentUpload(models.Model):
    ALLOWED_TYPES = [
        "application/pdf",
        "image/png",
        "image/jpeg",
    ]

    file = models.FileField(
        upload_to=secure_upload_path,
        validators=[MagicMimeValidator(allowed_mimes=ALLOWED_TYPES)],
    )
    uploaded_at = models.DateTimeField(auto_now_add=True)

Isolasi Storage dan Konfigurasi Header

Menyimpan dan menyajikan file media dari web root yang sama dengan server aplikasi adalah anti-pattern. Terapkan strategi pertahanan berlapis berikut:

1. Isolasi Fisik atau Subdomain Terpisah

Pisahkan berkas media statis ke server storage independen (misalnya Object Storage seperti AWS S3, Cloudflare R2) atau sajikan melalui subdomain khusus (misal: media.example.com) tanpa cookie sesi pengguna. Jika penyerang berhasil mengeksekusi XSS melalui berkas HTML/SVG terunggah, Same-Origin Policy (SOP) akan melindungi token autentikasi pada domain utama.

2. Header HTTP Pelindung (Nginx)

Jika berkas disajikan melalui Nginx, nonaktifkan eksekusi skrip dan paksa browser untuk tidak menebak MIME type melalui header X-Content-Type-Options: nosniff. Selain itu, paksa berkas yang berisiko diunduh sebagai lampiran, bukan dieksekusi inline:

# Konfigurasi Nginx untuk lokasi /media/
location /media/ {
    alias /var/www/myproject/media/;
    
    # Nonaktifkan eksekusi interpreter skrip
    location ~ \.(php|py|sh|pl|cgi)$ {
        deny all;
    }

    # Cegah MIME sniffing oleh browser
    add_header X-Content-Type-Options "nosniff" always;
    
    # Paksa download untuk format non-viewable aman
    add_header Content-Disposition "attachment" always;
    
    # Pembatasan eksekusi resource
    add_header Content-Security-Policy "default-src 'none'" always;
}

Verifikasi Pengujian: Django TestCase

Pengujian unit otomatis memastikan penolakan payload berbahaya yang menyamarkan ekstensinya. Berikut adalah TestCase mandiri menggunakan file dummy:

# tests.py
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.exceptions import ValidationError
from .models import DocumentUpload
from .validators import MagicMimeValidator

class FileUploadSecurityTests(TestCase):
    def setUp(self):
        self.validator = MagicMimeValidator(allowed_mimes=["image/png"])

    def test_reject_spoofed_php_shell(self):
        # Payload teks PHP dengan ekstensi .png
        fake_image_content = b"<?php system($_GET['cmd']); ?>"
        spoofed_file = SimpleUploadedFile(
            name="avatar.png",
            content=fake_image_content,
            content_type="image/png"
        )

        # Validator wajib mendeteksi tipe teks asli dan menolak
        with self.assertRaises(ValidationError):
            self.validator(spoofed_file)

    def test_accept_valid_magic_bytes(self):
        # 8-byte magic header standar file PNG asli
        valid_png_header = b"\x89PNG\r\n\x1a\n" + (b"\x00" * 200)
        valid_file = SimpleUploadedFile(
            name="avatar.png",
            content=valid_png_header,
            content_type="image/png"
        )

        try:
            self.validator(valid_file)
        except ValidationError:
            self.fail("MagicMimeValidator menolak header PNG yang valid.")

Kesimpulan dan Trade-offs

Inspeksi magic bytes menggunakan python-magic memerlukan dependensi biner libmagic yang harus ada di lingkungan runtime (OS target atau container Docker). Ini menambah sedikit overhead I/O saat proses upload karena membaca stream awal berkas, tetapi memberikan kepastian integritas tipe data.

Untuk mitigasi komprehensif, padukan validasi magic bytes ini dengan re-encoding berkas (misalnya membuka kembali gambar via Pillow dan menyimpannya ulang guna melucuti payload polyglot) dan simpan berkas di bucket storage yang terisolasi sepenuhnya dari komputasi backend.