Menangani upload file pada framework web asinkron seperti Actix Web menuntut penanganan I/O yang ketat. Mengumpulkan seluruh payload multipart ke dalam RAM rentan memicu Denial of Service (DoS) berbasis memory exhaustion. Selain itu, mempercayai header Content-Type atau ekstensi dari klien membuka celah MIME spoofing dan Arbitrary File Upload yang berpotensi berujung pada Remote Code Execution (RCE).
Artikel ini menyajikan implementasi hardening upload file pada Actix Web menggunakan streaming chunk-by-chunk, inspeksi magic bytes dengan crate infer, sanitasi nama file berbasis UUID v4, dan pembersihan file otomatis melalui idiom RAII.
Vektor Ancaman Endpoint Upload
Tiga kerentanan utama yang sering muncul pada implementasi upload standar:
- Memory Exhaustion (DoS): Membaca seluruh stream multipart ke
actix_web::web::BytesatauVec<u8>sebelum divalidasi. Jika 50 request konkuren mengunggah file 200 MB, alokasi memori server melonjak hingga 10 GB. - MIME Spoofing & Polyglot File: Penyerang mengirimkan web shell (skrip PHP/Perl) dengan header
Content-Type: image/pngdan nama fileshell.pngataushell.php;.png. - Path Traversal: Menggunakan nama file langsung dari header
Content-Dispositionklien (contoh:../../etc/cron.d/malicious) dapat menimpa file sistem krusial jika path tidak disanitasi.
Konfigurasi Dependensi
Tambahkan dependensi berikut ke dalam file Cargo.toml:
[dependencies]
actix-web = "4.9"
actix-multipart = "0.7"
futures-util = "0.3"
tokio = { version = "1.40", features = ["fs", "io-util"] }
infer = "0.16"
uuid = { version = "1.10", features = ["v4", "fast-rng"] }
derive_more = { version = "1.0", features = ["display", "error"] }
Pembersihan File Parsial dengan Pola RAII
Jika koneksi terputus di tengah proses streaming atau validasi gagal di pertengahan write, file parsial akan tertinggal di disk. Pola RAII (Resource Acquisition Is Initialization) memastikan file yang belum selesai dibatalkan secara deterministik saat variabel guard keluar dari scope.
use std::path::{Path, PathBuf};
pub struct PartialFileGuard {
path: PathBuf,
committed: bool,
}
impl PartialFileGuard {
pub fn new(path: PathBuf) -> Self {
Self {
path,
committed: false,
}
}
pub fn commit(&mut self) {
self.committed = true;
}
}
impl Drop for PartialFileGuard {
fn drop(&mut self) {
if !self.committed && self.path.exists() {
let _ = std::fs::remove_file(&self.path);
}
}
}
Implementasi Upload Handler
Handler berikut membatasi ukuran stream secara real-time pada setiap chunk, membaca 512 byte pertama untuk memverifikasi magic bytes asli, dan menulis stream langsung ke storage menggunakan asynchronous I/O.
use actix_multipart::Multipart;
use actix_web::{post, HttpResponse, ResponseError};
use derive_more::{Display, Error};
use futures_util::StreamExt;
use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; // 5 MB
const MAGIC_BYTES_SAMPLE_SIZE: usize = 512;
const UPLOAD_DIR: &str = "./uploads";
const ALLOWED_MIME_TYPES: &['static str] = &[
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
];
#[derive(Debug, Display, Error)]
pub enum UploadError {
#[display("Payload melebihi batas maksimum 5 MB")]
PayloadTooLarge,
#[display("Format file tidak diizinkan atau header biner tidak valid")]
InvalidFileType,
#[display("Gagal memproses stream multipart")]
MultipartError,
#[display("Kesalahan I/O disk lokal")]
IoError,
}
impl ResponseError for UploadError {
fn status_code(&self) -> actix_web::http::StatusCode {
match self {
Self::PayloadTooLarge => actix_web::http::StatusCode::PAYLOAD_TOO_LARGE,
Self::InvalidFileType => actix_web::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
Self::MultipartError => actix_web::http::StatusCode::BAD_REQUEST,
Self::IoError => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
#[post("/upload")]
pub async fn upload_file(mut payload: Multipart) -> Result<HttpResponse, UploadError> {
tokio::fs::create_dir_all(UPLOAD_DIR)
.await
.map_err(|_| UploadError::IoError)?;
while let Some(item) = payload.next().await {
let mut field = item.map_err(|_| UploadError::MultipartError)?;
// Lewatkan form field yang bukan file
let content_disposition = field.content_disposition();
if content_disposition.get_filename().is_none() {
continue;
}
let temp_filename = format!("{}.tmp", Uuid::new_v4());
let temp_filepath = PathBuf::from(UPLOAD_DIR).join(&temp_filename);
let mut file = File::create(&temp_filepath)
.await
.map_err(|_| UploadError::IoError)?;
let mut guard = PartialFileGuard::new(temp_filepath.clone());
let mut total_bytes: usize = 0;
let mut header_buffer = Vec::with_capacity(MAGIC_BYTES_SAMPLE_SIZE);
let mut extension: Option<String> = None;
while let Some(chunk_result) = field.next().await {
let chunk = chunk_result.map_err(|_| UploadError::MultipartError)?;
total_bytes += chunk.len();
// Batasi ukuran chunk langsung saat streaming
if total_bytes > MAX_FILE_SIZE {
return Err(UploadError::PayloadTooLarge);
}
// Kumpulkan sampel data pertama untuk validasi magic bytes
if header_buffer.len() < MAGIC_BYTES_SAMPLE_SIZE {
let bytes_needed = MAGIC_BYTES_SAMPLE_SIZE - header_buffer.len();
let bytes_to_take = chunk.len().min(bytes_needed);
header_buffer.extend_from_slice(&chunk[..bytes_to_take]);
if header_buffer.len() >= MAGIC_BYTES_SAMPLE_SIZE || extension.is_none() {
let detected_type = infer::get(&header_buffer)
.ok_or(UploadError::InvalidFileType)?;
if !ALLOWED_MIME_TYPES.contains(&detected_type.mime_type()) {
return Err(UploadError::InvalidFileType);
}
extension = Some(detected_type.extension().to_string());
}
}
file.write_all(&chunk).await.map_err(|_| UploadError::IoError)?;
}
file.flush().await.map_err(|_| UploadError::IoError)?;
drop(file);
let final_ext = extension.ok_or(UploadError::InvalidFileType)?;
let final_filename = format!("{}.{}", Uuid::new_v4(), final_ext);
let final_filepath = PathBuf::from(UPLOAD_DIR).join(final_filename);
// Rename atomik dari .tmp ke nama final UUID
tokio::fs::rename(&temp_filepath, &final_filepath)
.await
.map_err(|_| UploadError::IoError)?;
// Tandai guard agar tidak dihapus saat keluar scope
guard.commit();
return Ok(HttpResponse::Ok().json(serde_json::json!({
"status": "success",
"file": final_filepath.to_string_lossy()
})));
}
Err(UploadError::MultipartError)
}
Pengujian Integrasi Sederhana
Gunakan runtime test bawaan Actix Web untuk memvalidasi proteksi MIME spoofing dan batas ukuran file.
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{test, App};
#[actix_web::test]
async fn test_mime_spoofing_rejected() {
let app = test::init_service(App::new().service(upload_file)).await;
// Payload teks biasa menyamar sebagai file PNG
let boundary = "---------------------------974767299852498929531610575";
let body = format!(
"--{0}
Content-Disposition: form-data; name=\"file\"; filename=\"shell.png\"
Content-Type: image/png
<?php phpinfo(); ?>
--{0}--
",
boundary
);
let req = test::TestRequest::post()
.uri("/upload")
.insert_header((
"content-type",
format!("multipart/form-data; boundary={}", boundary),
))
.set_payload(body)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
}
Catatan Operasional & Trade-Offs
- Sample Buffer Size: Ukuran buffer 512 byte cukup untuk format umum seperti JPEG, PNG, dan PDF. Namun, beberapa format kontainer berbasis ZIP (misalnya
.docxatau.xlsx) membutuhkan hingga 4 KB atau inspeksi file central directory untuk identifikasi spesifik. - Storage Path Isolation: Jangan meletakkan direktori
uploads/di dalam direktori web root server (seperti direktori static filesactix-files) dengan hak eksekusi script. Letakkan di volume terisolasi atau bucket object storage eksternal (misal: S3/MinIO). - Throughput vs Latency: Menulis per-chunk langsung ke disk mengurangi pemakaian RAM, tetapi menghasilkan disk I/O kontinu. Jika throughput IOPS disk terbatas, pertimbangkan
tokio::io::BufWriteruntuk mem-batch penulisan chunk tanpa mengorbankan batasan memori.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!