Skip to content

Dari Buffer Penuh ke Streaming di Endpoint Unggahan

Adityo Guni Waluyo

Lima slot dikali 10 MiB per permintaan adalah undangan DoS. Rate limit per pengguna plus streaming multipart menurunkan memori ke sekitar 1 MiB.

Ringkasan

Handler lama nampung semua unggahan di RAM, jadi satu request bisa makan 50 MiB dan 100 pemakai serentak bikin 5 GiB. Turunin batas ukuran doang nggak nolong, memori per request dan frekuensi request itu beda masalah. Jadi dipasang rate limit 30 per menit plus streaming: cuma 1 MiB di RAM, sisanya ke disk, file dialirkan, akhirnya ~1 MiB per request.

50 MiB per Klik di Endpoint Tunggal

Formulir peminjaman di portal layanan publik menerima lima slot dokumen, masing-masing sampai 10 MiB. Handler lama membaca semuanya dengan io.ReadAll. Dalam skenario terburuk satu permintaan menyangga 50 MiB di RAM, dan parser multipart menahan salinan keduanya. Permintaan yang gagal di tengah jalan tidak mengembalikan memori lebih cepat; salinan hanya lewat garbage collection belakangan.

Dugaan awal saya: cukup turunkan batas ukuran berkas. Perhitungan sederhana membantahnya. Seratus pengguna terautentikasi yang mengirim bersamaan, bahkan dengan berkas sah, tetap memaksa server menyediakan sekitar 5 GiB hanya untuk penyangga. Batas per permintaan mengendalikan ukuran tiap gelombang, bukan jumlah gelombang. Dua masalah berbeda, memori per request dan frekuensi request, membutuhkan dua mekanisme yang berbeda juga.

Lapisan Frekuensi: Limiter per Pengguna

Pertahanan pertama adalah rate limit 30 permintaan per menit. Kuncinya dibangun dari ID pengguna yang sedang masuk; lalu lintas anonim jatuh ke bucket per-IP. Ketika batas tembus, server menjawab 429 RATE_LIMITED dengan header Retry-After, sehingga klien sah tahu kapan boleh mencoba lagi. Verifikasinya sederhana: kirim 31 permintaan dalam satu menit dengan token yang sama, permintaan ke-31 harus tersanggah tanpa menyentuh logika bisnis.

Lapisan Memori: Streaming, Bukan Menyangga

Pertahanan kedua mengubah cara berkas dibaca. Body permintaan dibungkus http.MaxBytesReader sebelum diparse. Dokumen resmi menjelaskan fungsi ini memang ditujukan untuk membatasi ukuran request body dan mencegah klien memboroskan resource server[3], dengan error MaxBytesError saat bacaan menembus batas[3].

r.Body = http.MaxBytesReader(w, r.Body, 55<<20) // total headroom, bukan per berkas

Parser kemudian dipanggil dengan jendela memori kecil. Kontrak ParseMultipartForm menjamin hanya maksimal maxMemory byte dari file parts yang tinggal di memori, sisanya otomatis ditulis ke berkas sementara di disk[3].

err := r.ParseMultipartForm(1 << 20) // 1 MiB di RAM, sisanya disk-backed
if err != nil {
    http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
    return
}

Setiap part lalu dialirkan ke storage dengan io.Copy, bukan dibaca penuh. Sebelum menulis, 512 byte pertama dibaca untuk magic-byte sniffing, pemeriksaan identitas murah agar ekstensi berkas tidak dipercaya buta. Selama penyalinan, batas 10 MiB per slot ditegakkan eksak. Jalur gagal mana pun, berkas parsial dihapus. Pemverifikasinya konkret: kirim berkas besar lalu putus koneksi di tengah jalan, tidak ada berkas sisa di direktori storage.

Hasil akhirnya: endpoint memegang sekitar 1 MiB per permintaan berapa pun ukuran berkasnya, dengan limiter yang menjawab pertanyaan yang tidak bisa dijawab penyangga, yaitu seberapa sering. Kombinasi unggahan streaming dan rate limit menutup dua vektor sekaligus: amplifikasi memori dan banjir permintaan.

Sumber

  1. http package - net/http - Go Packages (diakses 2026-10-12).

Artikel terkait