Kerentanan Validasi Upload Berbasis Ekstensi dan Header
Memvalidasi berkas unggahan hanya berdasarkan ekstensi nama file (filepath.Ext) atau header HTTP Content-Type membuka celah serangan Remote Code Execution (RCE) dan Stored XSS. Nilai Content-Type dikirim sepenuhnya oleh client melalui multipart body request, sehingga penyerang dapat memalsukan header tersebut secara bebas menggunakan HTTP client atau proxy interceptor.
Skenario bypass umum: penyerang mengunggah skrip web shell biner atau PHP dengan nama payload.png dan menyetel header menjadi Content-Type: image/png. Jika backend mempercayai metadata ini tanpa memeriksa struktur data biner aslinya, berkas berbahaya berhasil disimpan di server.
Prinsip Validasi Magic Byte
Magic byte (dikenal juga sebagai file signature) adalah urutan byte spesifik di awal berkas yang digunakan oleh sistem operasi dan parser untuk mengidentifikasi format data sebenarnya secara independen dari ekstensi nama file.
- PNG:
89 50 4E 47 0D 0A 1A 0A(ASCII:.PNG....) - JPEG:
FF D8 FF - PDF:
25 50 44 46(ASCII:%PDF) - GIF:
47 49 46 38(ASCII:GIF8)
Pustaka standar Go menyediakan http.DetectContentType(data []byte) yang membaca maksimal 512 byte pertama. Untuk kontrol keamanan yang deterministik, backend harus mengombinasikan deteksi MIME standar dengan pencocokan byte signature secara eksplisit terhadap format yang diizinkan (allowlist).
Arsitektur Upload Aman di Go Fiber
Alur pemrosesan upload yang aman membutuhkan empat tahapan berurutan:
- Batasi batas ukuran body secara global atau per-rute untuk mencegah DoS exhaustion memory.
- Buka multipart stream tanpa membaca seluruh isi berkas ke memory RAM.
- Baca 512 byte pertama, cocokkan signature dengan allowlist, lalu tutup handle.
- Simpan berkas menggunakan nama acak (UUID/random hex) ke direktori di luar web root publik (misal:
/var/app/storage/uploads).
Implementasi Kode: Validator dan Handler Go Fiber
Berikut implementasi fungsi verifikasi signature dan handler Fiber:
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"github.com/gofiber/fiber/v2"
)
var allowedSignatures = map[string][][]byte{
"image/png": {
{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
},
"image/jpeg": {
{0xFF, 0xD8, 0xFF},
},
"application/pdf": {
{0x25, 0x50, 0x44, 0x46},
},
}
var extensionMap = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"application/pdf": ".pdf",
}
func validateMagicBytes(file multipart.File) (string, error) {
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("gagal membaca header berkas: %w", err)
}
detectedMIME := http.DetectContentType(buffer[:n])
signatures, exists := allowedSignatures[detectedMIME]
if !exists {
return "", fmt.Errorf("tipe MIME %s tidak diizinkan", detectedMIME)
}
matched := false
for _, sig := range signatures {
if bytes.HasPrefix(buffer[:n], sig) {
matched = true
break
}
}
if !matched {
return "", errors.New("signature biner tidak cocok dengan MIME terdeteksi")
}
return detectedMIME, nil
}
func generateRandomFilename(ext string) (string, error) {
// ponytail: random hex stdlib avoids external uuid dep
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b) + ext, nil
}
func UploadHandler(storageDir string) fiber.Handler {
return func(c *fiber.Ctx) error {
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Field form 'file' wajib disertakan",
})
}
file, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Gagal memproses berkas",
})
}
defer file.Close()
mimeType, err := validateMagicBytes(file)
if err != nil {
return c.Status(fiber.StatusUnsupportedMediaType).JSON(fiber.Map{
"error": err.Error(),
})
}
ext := extensionMap[mimeType]
safeFilename, err := generateRandomFilename(ext)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Gagal membuat nama berkas",
})
}
destination := filepath.Join(storageDir, safeFilename)
// Simpan fileHeader langsung: Fiber membaca ulang multipart stream dari disk/temp
if err := c.SaveFile(fileHeader, destination); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Gagal menyimpan berkas ke storage",
})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "Berkas berhasil diverifikasi dan disimpan",
"file_name": safeFilename,
"mime_type": mimeType,
})
}
}
func SetupApp(storageDir string) *fiber.App {
app := fiber.New(fiber.Config{
BodyLimit: 5 * 1024 * 1024, // 5MB limit
})
app.Post("/upload", UploadHandler(storageDir))
return app
}
Pengujian Otomatis Menggunakan app.Test
Pengujian unit harus memastikan bahwa file asli diterima, sedangkan file skrip berbahaya yang disamarkan dengan ekstensi .png berhasil ditolak dengan status 415 Unsupported Media Type.
package main
import (
"bytes"
"io"
"mime/multipart"
"net/http/httptest"
"os"
"testing"
)
func createMultipartRequest(fieldName, fileName, contentType string, content []byte) (*httptest.ResponseRecorder, *bytes.Buffer, string, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
partHeader := make(map[string][]string)
partHeader["Content-Disposition"] = []string{`form-data; name="` + fieldName + `"; filename="` + fileName + `"`}
partHeader["Content-Type"] = []string{contentType}
part, err := writer.CreatePart(partHeader)
if err != nil {
return nil, nil, "", err
}
if _, err = part.Write(content); err != nil {
return nil, nil, "", err
}
if err = writer.Close(); err != nil {
return nil, nil, "", err
}
return httptest.NewRecorder(), body, writer.FormDataContentType(), nil
}
func TestUploadValidation(t *testing.T) {
storageDir, err := os.MkdirTemp("", "uploads-test-*")
if err != nil {
t.Fatalf("Gagal membuat temp dir: %v", err)
}
defer os.RemoveAll(storageDir)
app := SetupApp(storageDir)
t.Run("Valid PNG File", func(t *testing.T) {
validPNG := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00}
_, body, contentType, err := createMultipartRequest("file", "valid.png", "image/png", validPNG)
if err != nil {
t.Fatalf("Gagal membangun payload: %v", err)
}
req := httptest.NewRequest("POST", "/upload", body)
req.Header.Set("Content-Type", contentType)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("Request gagal: %v", err)
}
if resp.StatusCode != 201 {
t.Errorf("Ekspektasi HTTP 201, didapat: %d", resp.StatusCode)
}
})
t.Run("Spoofed MIME Type (PHP Disguised as PNG)", func(t *testing.T) {
maliciousPHP := []byte("<?php phpinfo(); ?>")
_, body, contentType, err := createMultipartRequest("file", "shell.png", "image/png", maliciousPHP)
if err != nil {
t.Fatalf("Gagal membangun payload: %v", err)
}
req := httptest.NewRequest("POST", "/upload", body)
req.Header.Set("Content-Type", contentType)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("Request gagal: %v", err)
}
if resp.StatusCode != 415 {
t.Errorf("Ekspektasi HTTP 415 (Rejected), didapat: %d", resp.StatusCode)
}
})
}
Trade-offs dan Mitigasi Tingkat Lanjut
Meskipun magic byte mengeliminasi bypass ekstensi dasar, sistem produksi memerlukan pertahanan berlapis (defense in depth):
- Polyglot Files: Berkas dapat dirancang valid sebagai gambar sekaligus berisi payload eksekusi (misal: metadata EXIF berisi kode shell). Lakukan re-encoding gambar menggunakan pustaka parser gambar jika berkas akan disajikan kembali ke pengguna.
- File System Hardening: Simpan direktori upload pada volume terpisah dengan opsi mount
noexec,nosuid,nodevuntuk mencegah OS mengeksekusi biner langsung dari storage. - Serving Berkas: Selalu sajikan berkas statis dengan header
Content-Disposition: attachmentdanX-Content-Type-Options: nosniffuntuk mencegah browser melakukan MIME sniffing pada berkas SVG atau HTML berbahaya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!