Prinsip “jangan percaya masukan apa pun” dari chapter sebelumnya kini kita turunkan menjadi kode. Setiap aplikasi yang kita bangun selama Course ini memiliki pintu masuk data: query string, request body, argumen CLI, hingga baris hasil query database. Chapter ini membedah dua pekerjaan yang sering dicampuradukkan tetapi sebenarnya berbeda — validasi yang memutuskan data layak atau tidak, dan sanitasi yang menyiapkan data agar aman dipakai di konteks tertentu. Kita akan melihat jebakan konversi angka di JavaScript, membangun fungsi validasi berbasis allow-list, dan memahami mengapa membersihkan data sekali di pintu masuk tidak pernah cukup.
Validasi Menjawab, Sanitasi Menyiapkan #
Validasi adalah pemeriksaan yang menjawab satu pertanyaan: apakah data ini layak diterima? Jawabannya ya atau tidak — data ditolak beserta alasannya, atau diloloskan ke langkah berikutnya. Sanitasi bekerja setelah keputusan itu: data yang sudah lolos tetap diubah bentuknya agar aman ketika dipakai, misalnya dienkode saat dirender ke HTML atau dinormalkan ke satu bentuk baku. Pemisahan keduanya penting karena keduanya hidup di waktu yang berbeda:
| Aspek | Validasi | Sanitasi |
|---|---|---|
| Pertanyaan | Apakah data ini layak diterima? | Bagaimana data dipakai dengan aman? |
| Bentuk tindakan | Menolak atau meloloskan | Mengubah: enkode, menormalkan, memangkas |
| Waktu eksekusi | Sekali, di pintu masuk | Setiap kali data keluar ke konteks baru |
| Contoh | umur wajib bilangan bulat 13-120 | < menjadi < saat masuk ke HTML |
| Bila gagal | Data ditolak dengan pesan galat | Data tetap tampil sebagai teks polos |
Kita telah menegakkan aturan serupa pada Chapter Validasi Input di Section REST API dari sisi protokol — status 400 dan 422 beserta pesan galatnya. Chapter ini melengkapi sudut pandangnya dari sisi keamanan: bagaimana kebiasaan konversi tipe JavaScript bisa membuka celah, dan mengapa allow-list menjadi filosofi dasar.
Konversi Angka Banyak Jebakannya #
Sumber galat validasi paling umum di JavaScript adalah sifat konversinya yang terlalu murah hati. Function Number() menerima hampir semua masukan dan berusaha sekuat tenaga menghasilkan angka — termasuk dari string kosong. Mari kita buktikan:
const coba = ['', ' ', null, '12px', '0x10', '5e2'];
for (const nilai of coba) {
console.log(`Number(${JSON.stringify(nilai)})`.padEnd(24), '->', Number(nilai));
}
console.log('parseInt("12.9", 10)'.padEnd(24), '->', parseInt('12.9', 10));Output yang Dihasilkan:
Number("") -> 0
Number(" ") -> 0
Number(null) -> 0
Number("12px") -> NaN
Number("0x10") -> 16
Number("5e2") -> 500
parseInt("12.9", 10) -> 12Perhatikan tiga jebakan pada hasil di atas. String kosong dan spasi menjadi 0 — field umur yang sengaja dikosongkan akan lolos sebagai umur nol tahun bila pemeriksaan rentangnya keliru. String "0x10" menjadi 16 karena Number() memahami notasi heksadesimal, dan "5e2" menjadi 500 karena notasi ilmiah juga diterima. Sementara itu parseInt("12.9", 10) memotong pecahan menjadi 12 tanpa error. Kesimpulannya satu kalimat: konversi bukan validasi. Konversi harus selalu diikuti pemeriksaan Number.isInteger() dan rentang nilai, seperti yang akan kita lihat pada demo utama.
Demo: Formulir Pendaftaran dengan Allow-List #
Demo berikut menerapkan pola parse lalu validasi lalu normalisasi untuk data pendaftaran. Seluruh aturan ditulis sebagai allow-list: nama harus string yang cukup panjang, email harus cocok pola sederhana, umur harus bilangan bulat pada rentang masuk akal, dan paket harus salah satu dari tiga nilai yang dikenal:
// Validasi dan normalisasi data pendaftaran: contoh pola
// parse -> validasi -> normalisasi dengan allow-list.
function validasiPendaftaran(input) {
const galat = [];
if (typeof input !== 'object' || input === null) {
return { galat: ['data pendaftaran wajib berupa object'] };
}
if (typeof input.nama !== 'string' || input.nama.trim().length < 3) {
galat.push('nama minimal 3 karakter');
}
if (typeof input.email !== 'string') {
galat.push('email wajib berupa teks');
} else if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email.trim()) === false) {
galat.push('format email tidak valid');
}
const umur = Number(input.umur);
if (Number.isInteger(umur) === false || umur < 13 || umur > 120) {
galat.push('umur harus bilangan bulat 13-120');
}
if (input.paket !== undefined && ['gratis', 'reguler', 'pro'].includes(input.paket) === false) {
galat.push('paket harus salah satu dari: gratis, reguler, pro');
}
return { galat };
}
function normalisasi(input) {
return {
nama: input.nama.trim(),
email: input.email.trim().toLowerCase(),
umur: Number(input.umur),
paket: input.paket ?? 'gratis',
};
}
const pendaftar = [
{ nama: 'Budi', email: '[email protected]', umur: '17', paket: 'pro' },
{ nama: 'Si', email: '[email protected]', umur: '17' },
{ nama: 'Siti', email: 'siti@@example', umur: 'tujuh belas', paket: 'eksklusif' },
{ nama: ' Dewi ', email: ' [email protected] ', umur: '21' },
];
for (const [i, data] of pendaftar.entries()) {
const { galat } = validasiPendaftaran(data);
if (galat.length > 0) {
console.log(`pendaftar #${i + 1}: DITOLAK - ${galat.join('; ')}`);
} else {
console.log(`pendaftar #${i + 1}: LOLOS - ${JSON.stringify(normalisasi(data))}`);
}
}Output yang Dihasilkan:
pendaftar #1: LOLOS - {"nama":"Budi","email":"[email protected]","umur":17,"paket":"pro"}
pendaftar #2: DITOLAK - nama minimal 3 karakter
pendaftar #3: DITOLAK - format email tidak valid; umur harus bilangan bulat 13-120; paket harus salah satu dari: gratis, reguler, pro
pendaftar #4: LOLOS - {"nama":"Dewi","email":"[email protected]","umur":21,"paket":"gratis"}Mari kita bedah aturan-aturannya satu per satu:
- Baris 6-8 (pemeriksaan bentuk paling awal): JSON yang valid belum tentu object —
"42",true, dannullsemuanya JSON sah. Pemeriksaan ini memastikan kode di bawahnya aman membaca properti. - Baris 10-12 (
nama): tipe diperiksa lebih dulu (typeof), panjang dihitung setelahtrim()— sehingga" ab "dihitung dua karakter, bukan enam. Menggabungkan keduanya dalam satu kondisi membuatinput.nama.trim()tidak pernah berjalan pada nilai non-string. - Baris 14-18 (
email): pola^[^\s@]+@[^\s@]+\.[^\s@]+$bukan validasi email sejati — tidak ada regex pendek yang sungguh menjaminnya — tetapi pola ini cukup untuk menolak masukan yang jelas rusak sepertisiti@@exampleyang tidak memiliki titik pada domainnya. - Baris 20-23 (
umur): inilah pola parse lalu validasi —Number(input.umur)mengubah string"17"menjadi angka, kemudianNumber.isInteger()ditambah rentang 13-120 memfilterNaN, pecahan, dan angka di luar nalar. String kosong yang berubah menjadi0juga tertahan oleh batas bawah. - Baris 25-27 (
paket): field opsional —undefineddibiarkan lolos — tetapi bila diisi, nilainya harus anggota allow-list. Nilai default diterapkan dinormalisasi, bukan di dalam validasi. - Baris 32-39 (
normalisasi): fungsi ini hanya dipanggil setelah daftar galat kosong; itulah sebabnya pemanggilaninput.nama.trim()di dalamnya aman. Bentuk baku — email lowercase, spasi dipangkas — disimpan sekali sehingga seluruh aplikasi bekerja pada data yang seragam. - Baris 41-56 (driver uji): empat pendaftar menguji jalur normal, nama terlalu pendek, tiga galat sekaligus yang terkumpul dalam satu baris (bukan berhenti di galat pertama), dan normalisasi yang merapikan spasi serta huruf besar.
Allow-List Melawan Deny-List #
Dua fungsi berikut punya tujuan sama — membersihkan teks dari tag — dengan filosofi yang berlawanan. Fungsi pertama membuang pola berbahaya yang diketahuinya; fungsi kedua membuang semua kecuali karakter yang diizinkannya:
// DENY-LIST: membuang pola berbahaya yang sudah diketahui.
function bersihkanDenyList(teks) {
return teks.replace('<script>', '');
}
// ALLOW-LIST: membuang semua kecuali karakter yang diizinkan.
function bersihkanAllowList(teks) {
return teks.replace(/[^a-zA-Z0-9 .,!?-]/g, '');
}
console.log(bersihkanDenyList('<SCRIPT>alert(1)</SCRIPT>'));
console.log(bersihkanDenyList('<<script>script>alert(1)</script>'));
console.log(bersihkanAllowList('Halo <SCRIPT>alert(1)</SCRIPT> semua!'));Output yang Dihasilkan:
<SCRIPT>alert(1)</SCRIPT>
<script>alert(1)</script>
Halo SCRIPTalert1SCRIPT semua!Deny-list kalah karena harus menebak semua variasi serangan: huruf besar lolos dari replace yang case-sensitive, dan <<script>script> tetap menyisakan <script> utuh setelah bagian tengahnya dibuang. Allow-list membalik bebannya — penyerang harus menemukan karakter yang diizinkan sekaligus berbahaya, dan daftar itu kita yang tentukan. Prinsip yang sama berlaku pada includes() untuk nilai enum dan pada pola regex: mendeskripsikan yang benar lebih kuat daripada mendeskripsikan yang salah.
Enkode untuk Konteks yang Tepat #
Sanitasi yang paling sering disalahpahami adalah keinginan “membersihkan data sekali untuk selamanya”. Data yang aman untuk satu konteks belum tentu aman untuk konteks lain: string yang benar untuk HTML body perlu aturan berbeda saat masuk ke atribut, ke URL, atau ke query SQL. Karena itu praktik terbaiknya bukan menyimpan versi “bersih” di database, melainkan menyimpan data mentah yang telah tervalidasi lalu mengenkode saat data keluar menuju konteksnya — escapeHtml untuk HTML, parameter terikat untuk SQL, argumen array untuk proses eksternal. Ketiga teknik itu masing-masing menjadi bintang tiga chapter berikutnya, dimulai dari yang paling terkenal: SQL injection.
Rujukan pendalaman: perilaku lengkap Number()
dan parseInt()
di MDN, termasuk aturan radix, serta panduan regular expression
untuk membangun pola allow-list yang lebih kompleks.