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.
Intisari
- Validasi di lapisan transport untuk bentuk, di domain untuk aturan bisnis. Keduanya perlu.
- Batasi ukuran body dengan
http.MaxBytesReadersebelum 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
| Lapisan | Memeriksa | Contoh |
|---|---|---|
| Transport | Bentuk: tipe, panjang, format, wajib/tidak | Email berformat email; jumlah adalah angka positif |
| Domain | Aturan: konsistensi dan keadaan sistem | Stok 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
| Sumber | Risiko | Pertahanan |
|---|---|---|
Parameter jalur {id} | Bukan angka, negatif, sangat besar | ParseInt + periksa rentang |
Query ?limit= | limit=1000000 menghabiskan memori | Batas maksimum yang keras (mis. 100) |
Query ?urut= | SQL injection lewat ORDER BY | Daftar putih, bukan pembersihan string |
Header X-Forwarded-For | Dipalsukan untuk melewati rate limit | Ambil hanya dari proxy tepercaya |
| Nama berkas unggahan | ../../etc/passwd | Buat nama sendiri; jangan pernah pakai nama dari klien |
| URL untuk diambil server | SSRF ke metadata EC2 | Daftar putih host; blokir alamat privat |
| Respons API pihak ketiga | Field kosong menyebabkan panic; body raksasa | io.LimitReader + periksa field |
| Pesan dari antrean | Muatan lama dengan skema berbeda | Beri 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.