Portal Call for Papers (CFP) publik sering menjadi target eksploitasi Denial of Service (DoS) dan eksekusi kode berbahaya karena menyediakan endpoint terbuka untuk menerima berkas dari pihak ketiga. Menyerahkan validasi hanya pada ekstensi berkas atau Content-Type header dari klien membuka celah Arbitrary File Upload dan Path Traversal.

Panduan ini membahas arsitektur pengerasan backend untuk memitigasi risiko tersebut dengan pendekatan berlapis: kontrol lalu lintas berbasis token-bucket, verifikasi signature berkas (magic bytes), sanitasi nama berkas deterministik, serta isolasi penyimpanan berkas.

1. Token-Bucket Rate Limiting per IP

Endpoint submission harus dilindungi dari spamming otomatis. Algoritma token-bucket membatasi rata-rata request per detik dengan tetap mengizinkan sedikit traffic burst yang valid. Implementasi in-memory standar menggunakan paket bawaan golang.org/x/time/rate cocok untuk instance tunggal.

package main

import (
	"net"
	"net/http"
	"sync"
	"golang.org/x/time/rate"
)

type IPRateLimiter struct {
	ips map[string]*rate.Limiter
	mu  sync.Mutex
	r   rate.Limit
	b   int
}

func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
	return &IPRateLimiter{
		ips: make(map[string]*rate.Limiter),
		r:   r,
		b:   b,
	}
}

func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
	i.mu.Lock()
	defer i.mu.Unlock()

	limiter, exists := i.ips[ip]
	if !exists {
		limiter = rate.NewLimiter(i.r, i.b)
		i.ips[ip] = limiter
	}
	return limiter
}

func RateLimitMiddleware(limiter *IPRateLimiter, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}

		if !limiter.GetLimiter(ip).Allow() {
			http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
			return
		}
		next(w, r)
	}
}
Catatan Produksi: Untuk arsitektur multi-instance di balik load balancer, gantikan state in-memory dengan cache terdistribusi seperti Redis menggunakan skrip Lua guna memastikan atomisitas penghitungan token. Ambil IP asli melalui header terpercaya seperti X-Forwarded-For yang sudah divalidasi oleh reverse proxy.

2. Validasi File Upload: Magic Bytes & MIME Sniffing

Header Content-Type pada request HTTP ditentukan oleh browser atau penyerang sehingga nilainya tidak dapat dipercaya. Backend wajib membaca 512 byte pertama berkas untuk memverifikasi struktur signature biner.

Untuk dokumen PDF, berkas valid harus diawali oleh sequence ASCII %PDF- (hexadecimal: 25 50 44 46 2d). Dokumen teks polos seperti Markdown dan Org-mode tidak memiliki signature biner statis, sehingga harus divalidasi melalui http.DetectContentType dan verifikasi encoding UTF-8 tanpa karakter kontrol mencurigakan.

3. Sanitasi Path dan Penyimpanan Terisolasi

Penyimpanan berkas menggunakan nama asli yang dikirim klien membuka celah Directory/Path Traversal (contoh: ../../etc/cron.d/exploit). Nama berkas asli harus dibuang.

  • Gunakan identifier acak baru seperti UUID v4 untuk nama fisik berkas di storage.
  • Gunakan filepath.Base untuk membuang seluruh direktori traversal jika nama asli tetap disimpan di database.
  • Pastikan path target berada di bawah root directory submission menggunakan pengecekan berbasis filepath.Rel atau strings.HasPrefix.
  • Simpan berkas di luar web server document root, atau gunakan Object Storage (S3-compatible) yang tidak mengeksekusi skrip dinamis.

4. Implementasi Handler Submission Lengkap

Berikut adalah kode minimalis backend Go yang mengintegrasikan validasi ukuran, inspeksi magic bytes, sanitasi path, dan penanganan error deterministik:

package main

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"unicode/utf8"
)

const (
	MaxUploadSize = 10 << 20 // 10 MB
	StorageDir    = "./secure_storage"
)

var allowedMIMEs = map[string]bool{
	"application/pdf": true,
	"text/plain":      true,
}

func generateSecureFilename(ext string) (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b) + ext, nil
}

func inspectPayload(header []byte) (string, error) {
	// Validasi eksplisit magic bytes PDF
	if bytes.HasPrefix(header, []byte("%PDF-")) {
		return "application/pdf", nil
	}

	// Fallback sniffing via net/http
	mime := http.DetectContentType(header)
	if mime == "text/plain; charset=utf-8" || mime == "text/plain" {
		if !utf8.Valid(header) {
			return "", errors.New("berkas teks mengandung byte non-UTF8")
		}
		return "text/plain", nil
	}

	return "", errors.New("tipe MIME berkas tidak didukung atau signature tidak valid")
}

func SubmitPaperHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
		return
	}

	// Batasi pembacaan body untuk mitigasi exhaustion DoS
	r.Body = http.MaxBytesReader(w, r.Body, MaxUploadSize)
	if err := r.ParseMultipartForm(MaxUploadSize); err != nil {
		http.Error(w, "Ukuran berkas melebihi limit 10MB", http.StatusBadRequest)
		return
	}

	file, fileHeader, err := r.FormFile("paper")
	if err != nil {
		http.Error(w, "Field paper tidak ditemukan", http.StatusBadRequest)
		return
	}
	defer file.Close()

	// Baca 512 byte awal untuk inspeksi format
	buffer := make([]byte, 512)
	n, err := file.Read(buffer)
	if err != nil && err != io.EOF {
		http.Error(w, "Gagal membaca isi berkas", http.StatusInternalServerError)
		return
	}

	detectedMIME, err := inspectPayload(buffer[:n])
	if err != nil || !allowedMIMEs[detectedMIME] {
		http.Error(w, "Konten berkas tidak valid: "+err.Error(), http.StatusUnprocessableEntity)
		return
	}

	// Kembalikan pointer pembacaan ke offset 0
	if _, err := file.Seek(0, io.SeekStart); err != nil {
		http.Error(w, "Gagal mereset stream berkas", http.StatusInternalServerError)
		return
	}

	// Normalisasi ekstensi berdasarkan mime yang terdeteksi, bukan nama asli
	ext := ".pdf"
	if detectedMIME == "text/plain" {
		ext = ".txt"
		if filepath.Ext(fileHeader.Filename) == ".org" {
			ext = ".org"
		} else if filepath.Ext(fileHeader.Filename) == ".md" {
			ext = ".md"
		}
	}

	secureName, err := generateSecureFilename(ext)
	if err != nil {
		http.Error(w, "Gagal mengalokasikan penyimpanan", http.StatusInternalServerError)
		return
	}

	destPath := filepath.Join(StorageDir, secureName)

	// Cegah path traversal
	cleanDest := filepath.Clean(destPath)
	if !filepath.HasPrefix(cleanDest, filepath.Clean(StorageDir)+string(filepath.Separator)) {
		http.Error(w, "Akses path tidak sah", http.StatusForbidden)
		return
	}

	out, err := os.OpenFile(cleanDest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
	if err != nil {
		http.Error(w, "Gagal menyimpan berkas di server", http.StatusInternalServerError)
		return
	}
	defer out.Close()

	if _, err := io.Copy(out, file); err != nil {
		http.Error(w, "Gagal menulis data ke disk", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
	w.Write([]byte(`{"status":"success","file_id":"` + secureName + `"}`))
}

5. Metadata Scrubbing & Isolasi Dokumen

Dokumen PDF dan format teks terstruktur sering kali mengandung metadata sensitif (informasi penulis, perangkat lunak compiler, path direktori lokal mesin pengirim) atau payload berbahaya seperti JavaScript internal pada form PDF.

  • PDF Sanitization: Integrasikan background worker (misal menggunakan tool CLI seperti pdfdetach atau exiftool) yang dijalankan di dalam container terisolasi (sandbox) untuk membuang tag /JavaScript, /Launch, dan membersihkan tag XMP metadata sebelum file diproses oleh reviewer.
  • Content Security Policy (CSP): Jika berkas diunduh langsung via browser, pastikan response header menyertakan:Content-Disposition: attachment; filename="..." dan Content-Type: application/octet-stream jika tidak ingin dirender langsung pada context origin aplikasi. Tambahkan X-Content-Type-Options: nosniff untuk mencegah browser me-reinterpretasi berkas.