Kembali ke Artikel

Belajar Golang dari Nol #27: Validasi Input, Jangan Percaya Data Luar

Solusi IT

Di Belajar Golang dari Nol #26 kita sudah mengatur siapa boleh melakukan apa lewat role dan otorisasi. Server kita sekarang tahu membedakan admin dan kasir. Tapi ada satu lubang yang belum kita tutup rapat. Bagaimana kalau data yang dikirim user itu sendiri yang bermasalah? Email tanpa tanda @. Password satu huruf. Field harga yang tidak pernah dikirim tapi diam-diam terbaca nol. Di bagian ini kita bereskan semuanya secara sistematis. Kalau kamu baru bergabung, mulai dari daftar lengkap seri dulu supaya tidak ada yang terlewat.

Semua Data dari Luar Itu Tersangka

Ini prinsip pertama dan paling penting. Server kamu tidak boleh percaya pada apa pun yang datang dari luar. Body JSON bisa diisi field aneh. Query param bisa berisi angka negatif. Path param bisa berisi string padahal kamu berharap angka. Header bisa dipalsukan. File upload bisa menyamar, seperti yang sudah kita bahas waktu memvalidasi file di bagian 20.

Kenapa harus separanoid itu? Karena request ke API kamu tidak selalu datang dari frontend yang kamu buat. Siapa pun bisa membuka terminal, mengetik perintah curl, dan mengirim apa saja ke endpoint kamu. Validasi di sisi frontend itu bagus untuk pengalaman user, tapi dari sudut pandang server, validasi frontend itu tidak ada. Anggap saja tidak pernah terjadi.

Prinsip kedua: validasi dilakukan di tepi, bukan berserakan. Maksudnya, begitu data masuk lewat handler, langsung periksa di situ. Jangan biarkan data mentah menyebar ke service, repository, lalu baru ketahuan bermasalah saat sudah setengah jalan diproses. Selama seri ini kita sudah beberapa kali menulis validasi kecil di sana-sini. Sekarang kita rapikan jadi satu pola yang konsisten.

Jebakan Decode JSON yang Diam-Diam

Decoder JSON bawaan Go itu sopan. Terlalu sopan, malah. Dia tidak protes kalau ada field yang salah ketik. Coba lihat struct ini.

type ProdukRequest struct {
	Nama  string `json:"nama"`
	Harga int    `json:"harga"`
}

Sekarang bayangkan frontend mengirim body seperti ini, dengan typo di field harga.

{"nama": "Kopi Susu", "hrga": 15000}

Decode berhasil tanpa error. Field hrga diabaikan begitu saja, dan Harga terisi nol karena itu zero value untuk int. Masih ingat konsep zero value dari bagian 5? Di sinilah dia menggigit balik. Produk seharga lima belas ribu tersimpan sebagai produk gratis, dan tidak ada satu pun error yang muncul.

Masalah kedua lebih halus lagi. Kalau Harga bernilai nol, kamu tidak bisa membedakan dua kemungkinan: apakah user memang mengirim nol karena produknya gratis, atau field itu tidak dikirim sama sekali? Keduanya menghasilkan nilai yang sama persis.

Ada tiga senjata untuk menutup jebakan ini. Pertama, DisallowUnknownFields supaya field yang tidak dikenal langsung ditolak. Kedua, pointer untuk membedakan kosong dan nol. Ketiga, batas ukuran body supaya orang tidak bisa mengirim payload raksasa, sama semangatnya dengan batas ukuran file upload di bagian 20.

type ProdukRequest struct {
	Nama  string `json:"nama"`
	Harga *int   `json:"harga"`
}

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // maksimal 1 MB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec.Decode(dst)
}

Dengan Harga bertipe *int, ceritanya jadi jelas. Kalau field tidak dikirim, nilainya nil. Kalau dikirim nol, nilainya pointer ke angka nol. Dua kondisi yang tadinya menyamar jadi satu, sekarang bisa dibedakan. Dan karena decodeJSON ini kita jadikan helper, semua handler memakai aturan yang sama tanpa harus mengulang kode.

Validasi Manual yang Rapi

Setelah decode berhasil, giliran memeriksa isinya. Pola yang enak dipakai: setiap struct request punya method Validate yang mengembalikan daftar error per field. Bukan satu error gabungan, tapi map yang memetakan nama field ke pesan kesalahannya.

import (
	"net/mail"
	"strings"
)

type RegisterRequest struct {
	Nama     string `json:"nama"`
	Email    string `json:"email"`
	Password string `json:"password"`
}

func (req RegisterRequest) Validate() map[string]string {
	errs := map[string]string{}

	if strings.TrimSpace(req.Nama) == "" {
		errs["nama"] = "nama wajib diisi"
	}
	if _, err := mail.ParseAddress(req.Email); err != nil {
		errs["email"] = "format email tidak valid"
	}
	if len(req.Password) < 8 {
		errs["password"] = "password minimal 8 karakter"
	}

	if len(errs) > 0 {
		return errs
	}
	return nil
}

Kenapa map, bukan error biasa? Karena frontend butuh tahu field mana yang salah supaya bisa menampilkan pesan tepat di bawah kolom isian yang bermasalah. Balasannya kita bungkus dalam JSON dengan status 400.

func kirimErrorValidasi(w http.ResponseWriter, errs map[string]string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusBadRequest)
	json.NewEncoder(w).Encode(map[string]any{"errors": errs})
}

Hasilnya seperti ini. Satu request, semua kesalahan dilaporkan sekaligus, bukan satu per satu.

{
  "errors": {
    "email": "format email tidak valid",
    "password": "password minimal 8 karakter"
  }
}

Bandingkan dengan API yang hanya membalas “input tidak valid” tanpa penjelasan. User mengisi ulang form sambil menebak-nebak salahnya di mana. Format per field ini kecil usahanya, besar dampaknya.

go-playground/validator, Jalan Singkat yang Populer

Menulis validasi manual untuk tiga field itu ringan. Tapi kalau aplikasimu punya dua puluh struct request dengan aturan mirip-mirip, kode Validate mulai terasa berulang. Di sinilah library go-playground/validator membantu. Aturan ditulis sebagai tag di struct.

import "github.com/go-playground/validator/v10"

type ProdukRequest struct {
	Nama  string `json:"nama" validate:"required,min=3,max=100"`
	Email string `json:"email" validate:"required,email"`
	Stok  int    `json:"stok" validate:"gte=0"`
}

var validate = validator.New()

Tag required menolak nilai kosong, email memeriksa format, min dan max membatasi panjang, gte=0 memastikan angka tidak negatif. Satu baris tag menggantikan beberapa baris if.

Masalahnya, pesan error bawaannya berbahasa mesin. Sesuatu seperti “Field validation for ‘Email’ failed on the ’email’ tag” jelas tidak pantas ditampilkan ke user. Kita terjemahkan sendiri jadi bahasa manusia, sekaligus dipetakan per field seperti pola sebelumnya.

func pesanValidasi(err error) map[string]string {
	errs := map[string]string{}
	for _, e := range err.(validator.ValidationErrors) {
		field := strings.ToLower(e.Field())
		switch e.Tag() {
		case "required":
			errs[field] = field + " wajib diisi"
		case "email":
			errs[field] = "format email tidak valid"
		case "min":
			errs[field] = field + " minimal " + e.Param() + " karakter"
		case "gte":
			errs[field] = field + " tidak boleh kurang dari " + e.Param()
		default:
			errs[field] = field + " tidak valid"
		}
	}
	return errs
}

Lalu kapan cukup manual dan kapan pakai library? Jujur saja: untuk aplikasi kecil dengan sedikit endpoint, validasi manual lebih mudah dibaca dan tidak menambah dependensi. Library mulai terasa manfaatnya saat jumlah struct request banyak dan aturannya standar. Yang penting bukan pilihan alatnya, tapi konsistensinya. Pilih satu pola, pakai di semua endpoint.

Bentuk Data vs Aturan Bisnis

Ada dua jenis validasi yang sering dicampur padahal tempatnya beda. Yang pertama validasi bentuk: apakah email formatnya benar, apakah password cukup panjang, apakah stok berupa angka. Ini urusan handler, karena tidak butuh melihat database atau kondisi sistem.

Yang kedua validasi aturan bisnis: apakah stok masih cukup kalau dikurangi jumlah pesanan, apakah email ini sudah dipakai akun lain, apakah user ini boleh menghapus data itu. Aturan seperti ini butuh konteks, biasanya butuh query ke database, dan tempatnya di layer service, sesuai pembagian tanggung jawab yang kita susun di bagian 16 dan kita praktikkan lagi di bagian 23.

Contoh konkret. “Stok tidak boleh minus” terdengar seperti validasi input, tapi sebenarnya bukan. Angka 5 itu bentuknya valid. Baru setelah service mengecek stok tersisa 3, ketahuan bahwa mengurangi 5 akan membuat stok minus. Handler tidak mungkin tahu itu. Jadi jangan paksakan semua pemeriksaan masuk ke Validate. Bentuk di handler, aturan bisnis di service. Pembagian ini membuat masing-masing lapisan tetap sederhana.

Sanitasi: Bereskan Saat Keluar, Bukan Saat Masuk

Sekarang topik yang sering disalahpahami. Banyak pemula berpikir input berbahaya harus dibersihkan sebelum disimpan, misalnya menghapus semua tag HTML dari nama user. Pendekatan yang lebih sehat justru sebaliknya: simpan apa adanya, lalu escape saat menampilkan.

Sebentar, apa itu XSS? Bayangkan ada user jahil yang mengisi nama dengan potongan kode JavaScript. Kalau aplikasimu menampilkan nama itu di halaman web mentah-mentah, browser pengunjung lain akan menjalankan kode tersebut seolah bagian sah dari situsmu. Kode itu bisa mencuri sesi, membajak akun, atau mengarahkan korban ke situs palsu. Itulah XSS, singkatan dari cross-site scripting: skrip orang lain yang menumpang lewat data.

Kabar baiknya, html/template yang kita pakai sejak bagian 21 sudah melakukan escaping otomatis. Setiap nilai yang dirender lewat template itu diubah jadi teks aman, sehingga kode jahat tampil sebagai tulisan biasa, bukan dieksekusi. Ini alasan yang sama kenapa kita pakai placeholder untuk SQL di bagian 12: biarkan alat yang tepat menangani escaping, jangan tempel string sendiri.

Lalu kenapa API yang hanya membalas JSON tetap perlu peduli? Karena browser kadang sok tahu. Kalau response tidak diberi Content-Type yang jelas, sebagian browser mencoba menebak jenis kontennya, dan tebakan itu bisa berujung pada JSON yang diperlakukan sebagai HTML. Pastikan setiap response JSON menyertakan header Content-Type: application/json, seperti yang sudah kita lakukan di helper tadi. Murah, satu baris, dan menutup satu pintu serangan.

Normalisasi, Validasi Kecil yang Mencegah Bug Besar

Ada satu langkah lagi sebelum data disimpan: normalisasi. Ini bukan soal menolak data, tapi merapikan bentuknya supaya konsisten. Tiga contoh yang paling sering menyelamatkan saya.

Pertama, strings.TrimSpace untuk semua input teks. Spasi tak sengaja di akhir nama atau email itu sangat umum, apalagi dari keyboard HP yang suka menambah spasi setelah autocomplete.

Kedua, email di-lowercase sebelum dicek unik. Waktu membangun register di bagian 25, kita menyimpan email sebagai identitas login. Kalau Budi@contoh.com dan budi@contoh.com dianggap dua akun berbeda, user akan bingung kenapa tidak bisa login padahal merasa sudah daftar. Normalkan dulu, baru bandingkan.

Ketiga, nomor HP dinormalkan ke awalan 62. User Indonesia menulis nomor dengan segala gaya: 0812, +62812, 62812, kadang pakai spasi atau strip. Kalau disimpan apa adanya, fitur kirim WhatsApp atau pencarian nomor akan kacau.

func normalisasiHP(hp string) string {
	hp = strings.TrimSpace(hp)
	hp = strings.ReplaceAll(hp, " ", "")
	hp = strings.ReplaceAll(hp, "-", "")

	switch {
	case strings.HasPrefix(hp, "+62"):
		return hp[1:]
	case strings.HasPrefix(hp, "0"):
		return "62" + hp[1:]
	}
	return hp
}

Letakkan normalisasi tepat sebelum validasi. Urutannya: decode, normalisasi, validasi bentuk, baru serahkan ke service. Dengan begitu validasi bekerja pada data yang sudah rapi.

Latihan: Memperkuat Endpoint Register

Sekarang kita gabungkan semuanya ke endpoint register yang kita buat di bagian 25. Dulu validasinya masih seadanya. Versi barunya membatasi body, menolak field asing, menormalkan input, lalu melaporkan error per field.

func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
	var req RegisterRequest
	if err := decodeJSON(w, r, &req); err != nil {
		kirimErrorValidasi(w, map[string]string{
			"body": "format JSON tidak valid atau ada field yang tidak dikenal",
		})
		return
	}

	// normalisasi dulu, baru validasi
	req.Nama = strings.TrimSpace(req.Nama)
	req.Email = strings.ToLower(strings.TrimSpace(req.Email))

	if errs := req.Validate(); errs != nil {
		kirimErrorValidasi(w, errs)
		return
	}

	user, err := h.service.Register(r.Context(), req)
	if err != nil {
		// aturan bisnis, misalnya email sudah terdaftar,
		// ditangani service seperti di bagian 25
		kirimErrorValidasi(w, map[string]string{"email": err.Error()})
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(user)
}

Uji dengan curl. Pertama, request yang benar.

curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"nama":"Budi Santoso","email":"Budi@contoh.com","password":"rahasia123"}'

Server membalas 201, dan email tersimpan sebagai budi@contoh.com berkat normalisasi. Sekarang tiga variasi gagal. Password terlalu pendek dan nama kosong sekaligus.

curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"nama":"  ","email":"budi@contoh.com","password":"123"}'

Balasan 400 dengan dua error sekaligus: nama wajib diisi, password minimal 8 karakter. Perhatikan nama berisi spasi saja tetap tertangkap karena TrimSpace berjalan sebelum validasi. Berikutnya, email rusak.

curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"nama":"Budi","email":"bukan-email","password":"rahasia123"}'

Balasan 400 dengan pesan format email tidak valid. Terakhir, field yang salah ketik.

curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"nama":"Budi","emial":"budi@contoh.com","password":"rahasia123"}'

Tanpa DisallowUnknownFields, typo emial akan lolos diam-diam dan email tersimpan kosong. Sekarang decoder langsung menolak, dan frontend tahu ada yang salah sejak detik pertama, bukan setelah data aneh masuk database.

Penutup

Hari ini kita menutup satu lubang besar dengan pola yang konsisten: semua data dari luar itu tersangka sampai terbukti valid. Decode dengan ketat, normalisasi, validasi bentuk di handler, aturan bisnis di service, escape saat menampilkan. Endpoint register kita sekarang jauh lebih tahan banting menghadapi input liar.

Backend kita makin lengkap, tapi ada satu masalah klasik yang belum tersentuh: frontend sering harus menebak bentuk request dan response API kita. Di bagian 28 kita bahas “Dokumentasi API dengan OpenAPI: Biar Frontend Tidak Menebak”.

Kalau kamu sedang membangun aplikasi untuk bisnismu dan ingin backend yang aman menangani input user sejak hari pertama, tim kami bisa membantu lewat jasa pengembangan sistem aplikasi. Sampai jumpa di bagian berikutnya.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel