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:

  1. Batasi batas ukuran body secara global atau per-rute untuk mencegah DoS exhaustion memory.
  2. Buka multipart stream tanpa membaca seluruh isi berkas ke memory RAM.
  3. Baca 512 byte pertama, cocokkan signature dengan allowlist, lalu tutup handle.
  4. 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,nodev untuk mencegah OS mengeksekusi biner langsung dari storage.
  • Serving Berkas: Selalu sajikan berkas statis dengan header Content-Disposition: attachment dan X-Content-Type-Options: nosniff untuk mencegah browser melakukan MIME sniffing pada berkas SVG atau HTML berbahaya.