Skip to content

Kolom Telepon Berisi 'bukan-angka': Lahirnya Validator Bersama

Adityo Guni Waluyo

Temuan QA sama muncul di dua modul admin: telepon non-digit diterima. Fix: satu validator backend, mirror frontend, satu kode error 400 INVALID_PHONE.

Ringkasan

Jadi gini, kolom telepon di KotaPortal lolos dimasukin huruf karena validasi cuma ada di klien, backend-nya kosong melompong. Solusinya satu fungsi allowlist di backend sebagai sumber kebenaran, terus di-mirror ke TypeScript di frontend biar error muncul inline. Libphonenumber nggak dipakai karena kegedean buat kebutuhan cek format doang.

Pertama kali buka tabel modul admin KotaPortal, saya menemukan hal yang janggal. Di kolom yang seharusnya berisi nomor telepon, ada baris data bernilai literal bukan-angka. Tidak ada error, tidak ada penolakan. Data mentah itu masuk dan tersimpan begitu saja, lalu ikut tampil ke halaman publik.

Tebakan pertama: ini kecelakaan di satu form. Solusi yang saya bayangkan sepele, tempel satu regex di dua editor yang berbeda, beres. Toh pengguna nggak mungkin sengaja ngetik huruf di kolom telepon kalau inputnya dibatasi di sisi klien.

Tebakan itu meleset di dua lapis. Sesi QA menemukan pola yang sama di dua modul berbeda, editor informasi dan editor wisata, dua komponen yang tidak saling kenal. Dan akar masalahnya bukan kelalaian pengguna: validasi format telepon memang tidak pernah ada di batas kepercayaan sistem. Validasi di klien itu lapisan kenyamanan, bukan benteng. Begitu validasi backend kosong, data apa pun menembus sampai ke database.

Satu Sumber Kebenaran di Backend

Perbaikannya dimulai dari satu fungsi di paket validator bersama backend, bukan dua regex kembar di dua editor. Prinsipnya allowlist persis seperti anjuran OWASP: definisikan apa yang diterima, tolak sisanya, jangan mencoba mengenali setiap string jahat [4]. Karakter yang diizinkan: tanda plus, digit, spasi, kurung, dan tanda hubung. RFC 3966 memang menyebut pemisah visual seperti kurung dan tanda hubung boleh ada di nomor telepon [5], jadi aturan ini mengikuti cara manusia ngetik, bukan cara komputer menyimpan. Panjang total dibatasi 3 sampai 20 karakter dengan minimal 7 digit, dan field-nya opsional: kosong artinya tanpa telepon, bukan data rusak.

// satu aturan, dipakai dua modul
func ValidatePhone(s string) error {
	if s == "" {
		return nil // field opsional
	}
	if len(s)  20 { // cap = kolom contact_phone VARCHAR(20)
		return ErrInvalidPhone
	}
	if !phoneRe.MatchString(s) { // allowlist: + digit spasi ( ) -
		return ErrInvalidPhone
	}
	if len(nonDigitRe.ReplaceAllString(s, "")) < 7 {
		return ErrInvalidPhone
	}
	return nil
}

Detail kecil yang menyelamatkan: ukuran aturannya mengikuti kolom database, bukan angka karangan. Rancangan awal validator memasang batas atas 25 karakter, lalu review pra-commit menangkap ketidaksesuaian itu dengan kolom contact_phone bertipe VARCHAR(20). Batasnya dipangkas ke 20 sebelum commit jalan, dan tes batas ditambahkan di dua sisi. Kalau angka ini dibiarkan beda, error baru bakal muncul belakangan di jalur database, jauh dari tempat penyebabnya.

Mirror di Frontend, Kontrak di Tengah

Backend adalah sumber kebenaran, tapi pengguna tidak hidup di sana. Jadi aturan yang sama ditulis ulang sebagai mirror TypeScript di frontend, lengkap dengan tes vitest-nya, dan dipasang di dua editor sekaligus: field Kontak di editor informasi, field Telepon di editor wisata. Fungsinya satu: error muncul inline sebelum request jalan, bukan setelah data ditolak server. Ketika backend tetap mengembalikan 400 dengan kode INVALID_PHONE, frontend memakai pesan inline yang sama, jadi pengguna tidak pernah melihat dua bahasa error untuk satu kesalahan yang sama. Handler backend memetakan satu ErrInvalidPhone ke satu respons, titik; tidak ada keluarga kode error yang harus dihafal dua sisi.

Kenapa tidak langsung pakai pustaka parser nomor telepon tingkat operator semacam libphonenumber Google [6]? Karena kebutuhannya beda kelas. KotaPortal butuh sanitasi format: data yang masuk harus terlihat seperti nomor telepon yang bisa dihubungi. Pustaka itu luar biasa untuk parsing nomor internasional lintas negara, tapi membawa dependensi besar untuk kebutuhan yang di sini cukup dijawab satu fungsi allowlist. Kalau suatu hari produk butuh validasi nomor per negara, jalurnya jelas: ganti isi validator, tanpa menyentuh dua modul pemakainya.

Pelajaran yang saya bawa: temuan QA yang muncul di dua tempat berbeda dengan luka yang sama itu bukan dua bug, itu satu desain yang hilang. Satu validator di sisi kebenaran, mirror di sisi kenyamanan, satu kode error sebagai jembatan, dan ukuran aturan yang dikunci ke kolom database. Dua modul tertutup oleh satu keputusan, dan kolom telepon berhenti menjadi tempat pembuangan teks apa pun.

Sumber

[4] OWASP Input Validation Cheat Sheet
[5] RFC 3966: The tel URI for Telephone Numbers
[6] libphonenumber