Hardening upload file di Rust berarti memperlakukan semua input file sebagai data tidak tepercaya. Pemeriksaan ekstensi saja tidak cukup, dan header Content-Type dari klien tidak boleh dianggap benar. Untuk backend web, pendekatan yang aman biasanya menggabungkan validasi MIME berbasis sniffing, allowlist ekstensi, batas ukuran request dan per file, nama file acak, penyimpanan di luar web root, serta kontrol abuse seperti rate limiting, kuota, dan observabilitas.
Artikel ini fokus pada implementasi praktis di ekosistem Rust web yang umum tanpa bergantung pada layanan cloud tertentu. Contoh kode dibuat dengan pendekatan yang relevan untuk framework seperti axum atau actix-web, tetapi prinsipnya tetap sama jika Anda memakai stack lain.
Ancaman umum pada endpoint upload file
Sebelum masuk ke implementasi, pahami ancaman yang paling sering muncul:
- File type spoofing: klien mengirim file berbahaya dengan ekstensi yang tampak aman, misalnya
.jpgpadahal isinya bukan gambar. - Path traversal: nama file seperti
../../etc/passwddipakai untuk menulis file ke lokasi yang tidak semestinya. - Resource exhaustion: penyerang mengirim file sangat besar, terlalu banyak file, atau request paralel untuk menghabiskan memori, disk, CPU, dan koneksi.
- Overwrite dan collision: file dengan nama sama menimpa file lain.
- Stored malicious content: file HTML, SVG, atau skrip diunggah lalu disajikan kembali dengan tipe konten yang salah, membuka peluang XSS atau konten aktif lain.
- CSRF: bila endpoint upload memakai autentikasi berbasis cookie, situs lain bisa memicu upload tanpa sepengetahuan pengguna jika tidak ada proteksi tambahan.
Karena itu, desain upload yang aman harus memvalidasi isi file, membatasi sumber daya, dan memastikan file tidak langsung bisa dieksekusi atau diakses secara sembarangan.
Prinsip hardening upload file di Rust
1. Jangan percaya Content-Type dari klien
Header Content-Type pada multipart part atau request berguna sebagai petunjuk, tetapi tidak boleh menjadi satu-satunya dasar validasi. Validasi yang lebih kuat adalah membaca beberapa byte awal file (magic bytes) lalu melakukan sniffing MIME.
Contohnya, file PNG biasanya memiliki signature yang konsisten pada awal file. Dengan membaca header file, server bisa membedakan apakah file benar-benar PNG, JPEG, PDF, ZIP, dan sebagainya. Ini mengurangi risiko file yang hanya menyamar lewat ekstensi atau header palsu.
2. Gunakan allowlist, bukan blocklist
Jika aplikasi hanya menerima gambar profil, buat aturan eksplisit: misalnya hanya image/png dan image/jpeg, dengan ekstensi .png, .jpg, dan .jpeg. Blocklist seperti “tolak .exe” biasanya tidak cukup karena format file yang berbahaya sangat banyak dan mudah dikamuflase.
3. Batasi ukuran di beberapa lapisan
Batas ukuran idealnya tidak hanya ada di satu tempat:
- Batas total request: mencegah request multipart yang terlalu besar diproses terlalu jauh.
- Batas per file: mencegah satu part file tumbuh melebihi ukuran yang diizinkan.
- Batas jumlah file: mencegah penyerang mengirim ratusan file kecil dalam satu request.
Ini penting karena satu batas saja sering tidak cukup. Misalnya, request total 20 MB masih bisa berisi 1.000 file kecil yang membebani parser, inode, dan metadata storage.
4. Simpan file di luar web root
File upload sebaiknya tidak langsung disimpan di direktori yang dilayani server web publik. Simpan di lokasi terpisah, lalu sediakan endpoint download terkontrol jika memang perlu diakses pengguna. Dengan cara ini, Anda bisa mengatur otorisasi, header response, dan Content-Disposition dengan aman.
5. Jangan gunakan nama file asli sebagai path penyimpanan
Nama file dari klien hanya cocok dipakai sebagai metadata. Untuk nama file fisik di disk, gunakan identifier acak atau UUID. Ini mencegah collision, path traversal, dan masalah karakter khusus pada sistem file.
Arsitektur alur upload yang lebih aman
Alur praktis yang cukup aman untuk banyak backend Rust:
- Terima request multipart dengan batas ukuran total.
- Untuk setiap part file, hentikan jika melewati batas ukuran per file.
- Baca sebagian byte awal untuk sniffing MIME.
- Validasi MIME hasil sniffing terhadap allowlist.
- Validasi ekstensi terhadap allowlist.
- Sanitasi nama file asli untuk metadata saja, jangan dipakai sebagai path final.
- Buat nama file acak untuk penyimpanan fisik.
- Simpan ke direktori di luar web root.
- Opsional: hitung checksum selama proses streaming untuk deduplikasi atau audit.
- Simpan metadata ke database: nama asli yang sudah disanitasi, ukuran, MIME terdeteksi, checksum, path internal, pemilik file, waktu upload.
- Catat log dan metrik untuk observabilitas serta deteksi abuse.
Implementasi praktis di Rust
Memilih buffering penuh vs streaming
Ada dua pendekatan umum saat memproses upload:
- Buffering penuh ke memori: lebih sederhana untuk file kecil karena mudah diinspeksi sebelum disimpan. Kekurangannya, memori cepat habis saat concurrency tinggi.
- Streaming ke file sementara: lebih cocok untuk produksi, terutama untuk file sedang hingga besar. Anda tetap bisa membaca sebagian byte awal untuk sniffing, lalu meneruskan stream ke disk sambil menghitung ukuran dan checksum.
Untuk endpoint produksi, streaming biasanya lebih aman terhadap tekanan memori. Namun, implementasinya lebih kompleks: Anda perlu menangani file sementara, rollback saat validasi gagal, dan pembersihan jika request terputus di tengah jalan.
Contoh alur validasi upload
Contoh berikut menunjukkan pola yang bisa diterapkan di handler Rust. Kode ini bersifat praktis dan sengaja dibuat generik agar mudah diadaptasi ke framework web yang Anda gunakan.
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
use sha2::{Digest, Sha256};
const MAX_REQUEST_BYTES: u64 = 10 * 1024 * 1024;
const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024;
const MAX_FILES: usize = 3;
fn sanitize_filename_for_metadata(input: &str) -> String {
let mut out = input
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ' '))
.collect::<String>();
out = out.trim().trim_matches('.').to_string();
if out.is_empty() {
"file".to_string()
} else {
out
}
}
fn allowed_extension(ext: &str) -> bool {
matches!(ext, "png" | "jpg" | "jpeg" | "pdf")
}
fn allowed_mime(mime: &str) -> bool {
matches!(mime, "image/png" | "image/jpeg" | "application/pdf")
}
fn generate_storage_name(detected_mime: &str) -> String {
let ext = match detected_mime {
"image/png" => "png",
"image/jpeg" => "jpg",
"application/pdf" => "pdf",
_ => "bin",
};
format!("{}.{}", Uuid::new_v4(), ext)
}
async fn store_upload_example(
original_filename: &str,
chunks: Vec<bytes::Bytes>,
upload_root: &Path,
) -> Result<(String, String, u64, String), String> {
let safe_name = sanitize_filename_for_metadata(original_filename);
let ext = Path::new(&safe_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if !allowed_extension(&ext) {
return Err("ekstensi file tidak diizinkan".into());
}
let mut size: u64 = 0;
let mut header = Vec::new();
let mut hasher = Sha256::new();
// Kumpulkan sebagian byte awal untuk sniffing.
for chunk in &chunks {
size += chunk.len() as u64;
if size > MAX_FILE_BYTES {
return Err("file melebihi batas ukuran".into());
}
if header.len() < 8192 {
let remain = 8192 - header.len();
header.extend_from_slice(&chunk[..chunk.len().min(remain)]);
}
}
let detected_mime = infer::get(&header)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream");
if !allowed_mime(detected_mime) {
return Err("MIME file tidak diizinkan".into());
}
let storage_name = generate_storage_name(detected_mime);
let storage_path: PathBuf = upload_root.join(&storage_name);
let mut file = tokio::fs::File::create(&storage_path)
.await
.map_err(|_| "gagal membuat file")?;
for chunk in chunks {
hasher.update(&chunk);
file.write_all(&chunk)
.await
.map_err(|_| "gagal menulis file")?;
}
file.flush().await.map_err(|_| "gagal flush file")?;
let checksum = format!("{:x}", hasher.finalize());
Ok((safe_name, detected_mime.to_string(), size, checksum))
}Poin penting dari contoh di atas:
- Nama file asli disanitasi hanya untuk metadata, bukan untuk nama file fisik.
- Ekstensi diperiksa sebagai sinyal tambahan, tetapi bukan satu-satunya validasi.
- MIME dideteksi dari isi file memakai sniffing, bukan dari header klien.
- Nama file penyimpanan dibuat acak untuk mencegah collision dan traversal.
- Checksum SHA-256 dihitung selama penulisan untuk audit atau deduplikasi opsional.
Jika Anda mengimplementasikan streaming murni dari multipart reader ke file, pola yang sama tetap berlaku: baca chunk, akumulasi header awal untuk sniffing, hitung ukuran, update checksum, lalu tulis ke file sementara. Jika validasi gagal, hapus file sementara dan hentikan request.
Validasi MIME berbasis sniffing: kenapa perlu digabung dengan ekstensi?
Sniffing MIME lebih dapat dipercaya daripada header klien, tetapi tetap punya batas. Beberapa format sulit diidentifikasi secara pasti jika hanya melihat byte awal. Selain itu, aplikasi Anda mungkin punya aturan bisnis berbasis ekstensi. Karena itu, pendekatan yang umum adalah:
- sniffing MIME untuk memverifikasi isi file, dan
- allowlist ekstensi untuk konsistensi kebijakan aplikasi.
Jika keduanya tidak cocok, ada dua pilihan:
- Strict mode: tolak upload.
- Normalize mode: terima file berdasarkan MIME hasil sniffing lalu simpan dengan ekstensi yang sesuai MIME terdeteksi, bukan ekstensi asli.
Untuk aplikasi yang sensitif, strict mode biasanya lebih aman.
Mencegah path traversal
Path traversal biasanya muncul saat server langsung menggabungkan path upload dengan nama file dari pengguna. Hindari pola seperti:
// Jangan lakukan ini
let path = upload_root.join(user_supplied_filename);Masalahnya bukan hanya ../, tetapi juga separator path platform-spesifik, karakter aneh, nama perangkat khusus di beberapa OS, dan collision. Solusi yang lebih aman:
- Jangan gunakan nama file pengguna sebagai nama path final.
- Gunakan nama acak yang dihasilkan server.
- Simpan nama file asli yang sudah disanitasi sebagai metadata terpisah.
- Pastikan path akhir tetap berada di bawah direktori upload yang diharapkan jika Anda melakukan operasi path lanjutan.
Penyimpanan aman di luar web root
Simpan file upload di direktori privat, misalnya /var/app/uploads atau path lain yang tidak dilayani langsung oleh server HTTP. Saat file perlu diunduh:
- ambil metadata dari database,
- verifikasi bahwa pengguna berhak mengakses file,
- kirim file dengan header yang aman, misalnya
Content-Disposition: attachmentbila sesuai.
Keuntungan pendekatan ini:
- file tidak dapat diakses hanya dengan menebak URL,
- Anda bisa menerapkan otorisasi per file,
- Anda bisa mengontrol Content-Type response berdasarkan metadata yang sudah diverifikasi.
Batas ukuran, status code, dan penanganan error
Batas ukuran request vs per file
Keduanya sering tertukar, padahal fungsinya berbeda:
- Batas ukuran request cocok dikembalikan sebagai
413 Payload Too Largejika request total terlalu besar. - Batas per file juga bisa memakai
413jika satu file melewati limit yang didokumentasikan. - Tipe file tidak didukung cocok dengan
415 Unsupported Media Type. - Input multipart rusak atau field tidak valid cocok dengan
400 Bad Request. - Tidak lolos autentikasi atau otorisasi gunakan
401atau403sesuai konteks. - Terlalu banyak request gunakan
429 Too Many Requests.
Hindari mengembalikan pesan error yang terlalu detail untuk aktor tak tepercaya. Cukup spesifik untuk debugging klien, tetapi jangan membocorkan path internal, struktur storage, atau validasi keamanan yang terlalu rinci.
Debugging tip untuk limit ukuran
Masalah limit sering muncul karena parser multipart, reverse proxy, dan aplikasi punya batas berbeda. Jika upload gagal tidak konsisten:
- pastikan reverse proxy juga mengizinkan ukuran yang sama atau lebih besar dari aplikasi,
- log ukuran request yang diterima aplikasi,
- bedakan error dari parser multipart dan error validasi bisnis Anda.
Jika tidak, Anda akan sulit membedakan apakah request ditolak oleh proxy, framework, atau handler upload Anda sendiri.
Pencegahan abuse: rate limiting, kuota, dan CSRF
Rate limiting
Rate limiting membatasi seberapa sering klien boleh memanggil endpoint upload. Ini penting karena upload jauh lebih mahal daripada request JSON biasa. Praktiknya:
- batasi per IP untuk endpoint publik atau anonim,
- batasi per user ID untuk endpoint yang memerlukan login,
- gunakan jendela waktu pendek untuk menahan burst, misalnya beberapa upload per menit sesuai kebutuhan aplikasi.
Implementasi bisa memakai penyimpanan in-memory untuk node tunggal, atau Redis jika aplikasi berjalan pada beberapa instance. Untuk API produksi multi-instance, penyimpanan terpusat biasanya lebih konsisten.
Kuota sederhana
Rate limiting mengontrol frekuensi, sedangkan kuota mengontrol akumulasi penggunaan. Contoh kuota sederhana:
- maksimal total storage per user,
- maksimal jumlah file per user,
- maksimal ukuran upload harian.
Ini mencegah abuse yang lolos dari rate limit, misalnya pengguna mengunggah file sah tetapi terus-menerus sampai storage penuh. Kuota biasanya lebih mudah dikelola jika metadata file disimpan di database.
Proteksi CSRF untuk upload berbasis cookie session
Jika autentikasi upload memakai cookie session browser, maka endpoint upload berpotensi terkena CSRF. Solusinya:
- gunakan token CSRF yang diverifikasi server,
- validasi header seperti
OriginatauRefererbila sesuai dengan model ancaman Anda, - atur atribut cookie seperti
SameSitesecara tepat, dengan memahami trade-off kompatibilitas aplikasi.
Jika upload dilakukan lewat API dengan token bearer di header Authorization dan tidak mengandalkan cookie browser, risiko CSRF biasanya berbeda dan sering lebih kecil. Namun, itu bukan alasan untuk mengabaikan autentikasi dan otorisasi yang benar.
Logging dan metrik untuk deteksi abuse
Upload yang aman bukan hanya soal menolak request berbahaya, tetapi juga mampu melihat pola serangan. Minimal, catat:
- user ID atau identitas principal jika ada,
- IP atau identitas jaringan sesuai kebijakan privasi Anda,
- waktu request,
- ukuran file dan ukuran total request,
- MIME terdeteksi, ekstensi asli, dan hasil validasi,
- status akhir: diterima, ditolak limit, ditolak MIME, ditolak rate limit, gagal write, dan sebagainya.
Metrik yang berguna antara lain:
- jumlah upload sukses dan gagal per alasan,
- persentase penolakan karena
413,415, dan429, - ukuran rata-rata dan distribusi ukuran file,
- jumlah upload per user/IP per interval waktu,
- latensi upload end-to-end.
Dengan metrik ini, Anda bisa membedakan lonjakan pengguna normal dari pola abuse seperti percobaan brute-force MIME, flood file kecil, atau eksploitasi parser multipart.
Jangan log isi file, path internal sensitif, atau data pribadi yang tidak perlu. Log harus membantu investigasi tanpa membuat kebocoran data baru.
Trade-off implementasi yang perlu dipahami
Streaming lebih hemat memori, tetapi lebih rumit
Streaming sangat cocok untuk produksi karena tidak menahan seluruh file di memori. Namun Anda perlu menangani:
- file sementara yang harus dibersihkan saat error,
- validasi yang terjadi sambil data masih mengalir,
- kondisi koneksi putus di tengah upload,
- sinkronisasi metadata database dengan file fisik agar tidak orphan.
Jika aplikasi Anda hanya menerima file kecil dan volume rendah, buffering mungkin cukup. Tetapi untuk endpoint publik atau trafik tinggi, streaming biasanya lebih masuk akal.
Checksum opsional: berguna, tetapi ada biaya CPU
Checksum seperti SHA-256 bermanfaat untuk:
- audit integritas,
- deduplikasi,
- mendeteksi re-upload file identik.
Namun hashing menambah biaya CPU. Jika file besar dan throughput tinggi, pertimbangkan apakah checksum harus selalu dihitung atau hanya untuk tipe file/fitur tertentu.
Sanitasi nama file bukan pengganti nama acak
Sanitasi tetap berguna untuk menampilkan nama file ke pengguna atau menyimpannya sebagai metadata. Tetapi sanitasi bukan alasan untuk memakai nama file itu sebagai nama penyimpanan. Nama acak tetap lebih aman dan lebih sederhana untuk mencegah collision dan traversal.
Checklist produksi untuk hardening upload file di Rust
- Gunakan parser multipart dengan batas ukuran request.
- Terapkan batas ukuran per file dan batas jumlah file.
- Validasi MIME berbasis sniffing, jangan hanya percaya header klien.
- Gunakan allowlist ekstensi dan cocokkan dengan kebijakan MIME.
- Sanitasi nama file asli hanya untuk metadata.
- Gunakan nama file acak untuk penyimpanan fisik.
- Simpan file di luar web root.
- Pastikan tidak ada path traversal saat membentuk path.
- Pertimbangkan streaming ke file sementara untuk menghemat memori.
- Opsional: hitung checksum selama streaming.
- Terapkan rate limiting per IP atau per user.
- Terapkan kuota total storage atau jumlah file per user.
- Jika memakai cookie session, aktifkan proteksi CSRF.
- Log alasan penolakan dan buat metrik untuk deteksi abuse.
- Samakan limit di aplikasi dan reverse proxy agar debugging lebih mudah.
- Pastikan file yang diunduh kembali dilayani melalui endpoint terkontrol dengan otorisasi yang benar.
Penutup
Hardening upload file di Rust tidak bergantung pada satu trik tunggal. Keamanannya datang dari kombinasi beberapa lapisan: validasi MIME dengan sniffing, allowlist ekstensi, pembatasan ukuran, penyimpanan aman di luar web root, nama file acak, pencegahan path traversal, serta kontrol abuse seperti rate limiting, kuota, CSRF, dan observabilitas.
Jika Anda sedang membangun endpoint upload untuk backend Rust, prioritaskan desain yang streaming-friendly, mudah diaudit, dan eksplisit dalam menolak input yang tidak sesuai. Pendekatan ini biasanya lebih tahan terhadap kesalahan implementasi dibanding mengandalkan pemeriksaan dangkal seperti ekstensi atau header dari klien.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!