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::Bytes atau Vec<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/png dan nama file shell.png atau shell.php;.png.
  • Path Traversal: Menggunakan nama file langsung dari header Content-Disposition klien (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 .docx atau .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 files actix-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::BufWriter untuk mem-batch penulisan chunk tanpa mengorbankan batasan memori.