← Semua pembelajaran / Go Nol → Enterprise
Fase 6 · Keamanan

Validasi input — gerbang data tidak tepercaya

Validasi bukan sekadar mencegah error 500. Ia batas tempat data tak dikenal berubah jadi data yang boleh dipercaya sisa aplikasimu — dan batas itu harus punya satu tempat yang jelas.

Sumber asli pkg.go.dev Resmi Rangkuman ~7 menit baca

Intisari

  • Validasi di lapisan transport untuk bentuk, di domain untuk aturan bisnis. Keduanya perlu.
  • Batasi ukuran body dengan http.MaxBytesReader sebelum mem-parse apa pun.
  • Parameter jalur dan query juga input: /produk/{id} bisa berisi apa saja.
  • Kembalikan semua kesalahan validasi sekaligus, dengan nama field — bukan satu per satu.
  • Jangan pernah memantulkan input mentah ke pesan error; itu jalur XSS dan kebocoran informasi.

Dua lapisan validasi

LapisanMemeriksaContoh
TransportBentuk: tipe, panjang, format, wajib/tidakEmail berformat email; jumlah adalah angka positif
DomainAturan: konsistensi dan keadaan sistemStok cukup; kupon belum kedaluwarsa; email belum terdaftar

Kenapa keduanya, bukan salah satu. Validasi transport melindungi dari input yang bahkan tidak masuk akal untuk diproses — dan ia menjaga aturan domain tetap bersih dari pemeriksaan tipe. Validasi domain adalah satu-satunya yang benar-benar mengikat, karena worker dan perintah CLI tidak melewati lapisan HTTP sama sekali.

Membaca body dengan aman

func bacaJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) {
	var v T

	// 1. Tolak Content-Type yang salah — mencegah sebagian serangan CSRF.
	if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		return v, ErrTipeKontenSalah
	}

	// 2. Batasi ukuran SEBELUM membaca. Tanpa ini, body 2 GB akan
	//    dibaca ke memori dan container-mu mati kehabisan RAM.
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // 1 MB

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(&v); err != nil {
		var maks *http.MaxBytesError
		switch {
		case errors.As(err, &maks):
			return v, fmt.Errorf("body maksimal %d byte: %w", maks.Limit, ErrValidasi)
		default:
			// JANGAN memantulkan err.Error() ke pengguna: ia bisa memuat
			// potongan input dan nama field internal.
			return v, fmt.Errorf("JSON tidak valid: %w", ErrValidasi)
		}
	}

	// 3. Tolak body kedua ("{}{}"): tanda klien yang bermasalah.
	if dec.More() {
		return v, fmt.Errorf("body harus satu objek JSON: %w", ErrValidasi)
	}
	return v, nil
}

Validasi berbasis tag

type buatProdukReq struct {
	Nama      string `json:"nama"       validate:"required,min=3,max=200"`
	Harga     int64  `json:"harga"      validate:"required,gt=0,lte=1000000000"`
	KategoriID int64 `json:"kategoriId" validate:"required,gt=0"`
	Email     string `json:"email"      validate:"omitempty,email"`
	Status    string `json:"status"     validate:"required,oneof=draf terbit arsip"`
	Tag       []string `json:"tag"      validate:"max=10,dive,min=1,max=30"`
}

var validasi = validator.New(validator.WithRequiredStructEnabled())

func periksa(v any) error {
	err := validasi.Struct(v)
	if err == nil {
		return nil
	}

	var ve validator.ValidationErrors
	if !errors.As(err, &ve) {
		return err
	}

	// Kembalikan SEMUA kesalahan sekaligus, dengan nama field yang
	// dikenal klien — bukan nama field Go.
	bidang := make(map[string]string, len(ve))
	for _, e := range ve {
		bidang[namaJSON(e.Field())] = pesanRamah(e)
	}
	return &ErrValidasi{Bidang: bidang}
}
HTTP/1.1 422 Unprocessable Entity

{
  "error": "validasi gagal",
  "bidang": {
    "nama":  "minimal 3 karakter",
    "harga": "harus lebih besar dari 0"
  }
}

Mengembalikan satu kesalahan per permintaan adalah pengalaman pengguna yang buruk — formulir dengan sepuluh field jadi butuh sepuluh kali kirim. Kumpulkan semuanya. Ini juga sebabnya errors.Join (Fase 1) sering muncul di lapisan validasi.

Validasi tanpa pustaka

// Untuk aplikasi kecil, ini sering lebih jelas daripada tag —
// dan pesannya bisa persis seperti yang kamu mau.
func (r buatProdukReq) Validasi() error {
	e := map[string]string{}

	if n := strings.TrimSpace(r.Nama); len(n) < 3 || len(n) > 200 {
		e["nama"] = "panjang harus 3–200 karakter"
	}
	if r.Harga <= 0 {
		e["harga"] = "harus lebih besar dari 0"
	}
	if !slices.Contains([]string{"draf", "terbit", "arsip"}, r.Status) {
		e["status"] = "harus draf, terbit, atau arsip"
	}

	if len(e) > 0 {
		return &ErrValidasi{Bidang: e}
	}
	return nil
}

Tag validator memakai refleksi, jadi salah ketik nama aturan (requird) baru ketahuan saat runtime — dan kalau aturannya tidak dikenal, pustaka ini panik. Tulis tes untuk setiap struct permintaan yang mengirim data tidak valid dan memeriksa pesannya; itu menangkap salah ketik sekaligus mendokumentasikan kontrak API.

Input yang sering lupa divalidasi

SumberRisikoPertahanan
Parameter jalur {id}Bukan angka, negatif, sangat besarParseInt + periksa rentang
Query ?limit=limit=1000000 menghabiskan memoriBatas maksimum yang keras (mis. 100)
Query ?urut=SQL injection lewat ORDER BYDaftar putih, bukan pembersihan string
Header X-Forwarded-ForDipalsukan untuk melewati rate limitAmbil hanya dari proxy tepercaya
Nama berkas unggahan../../etc/passwdBuat nama sendiri; jangan pernah pakai nama dari klien
URL untuk diambil serverSSRF ke metadata EC2Daftar putih host; blokir alamat privat
Respons API pihak ketigaField kosong menyebabkan panic; body raksasaio.LimitReader + periksa field
Pesan dari antreanMuatan lama dengan skema berbedaBeri versi pada muatan; validasi saat konsumsi

SSRF layak disebut khusus di AWS. Endpoint yang mengambil URL pilihan pengguna bisa diarahkan ke http://169.254.169.254/ — layanan metadata instans — dan pada IMDSv1 itu berarti kredensial IAM peranmu bocor. Pertahanannya berlapis: daftar putih host di aplikasi, dan memaksa IMDSv2 pada task ECS-mu (Fase 10).

Batasi ukuran di setiap titik masuk

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)          // JSON: 1 MB
r.ParseMultipartForm(10 << 20)                           // unggahan: 10 MB
io.Copy(io.Discard, io.LimitReader(resp.Body, 5<<20))    // respons luar: 5 MB
srv.MaxHeaderBytes = 1 << 20                             // header: 1 MB

Latihan: buat endpoint yang menerima JSON tanpa MaxBytesReader, lalu kirim body 500 MB dengan head -c 500M /dev/zero | curl --data-binary @- dan amati memori prosesmu. Pasang batasnya dan ulangi. Lalu tambahkan pengurutan lewat query ?urut= dengan daftar putih, dan coba kirim ?urut=harga;DROP TABLE produk.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.