Prinsip trust boundary pada Bab sebelumnya mengajarkan satu hal tegas: data dari luar tidak dipercaya sampai dibuktikan. Input validation adalah titik pemeriksaan itu — gerbang pertama yang harus dilalui setiap byte dari pengguna, berkas, atau layanan lain sebelum menyentuh logika internal aplikasi. Tanpa gerbang ini, string berisi ../ bisa menjelajahi sistem berkas, potongan SQL bisa mengubah query, dan data berukangan gigabyte bisa menghabiskan memori. Bab ini membahas tiga kebiasaan inti — memvalidasi di titik masuk, memakai allowlist, dan mem-parse alih-alih memeriksa bentuk teks — lalu membuktikannya dengan validator yang menolak daftar masukan jahat secara deterministik.
Validasi di Titik Masuk #
Aturan pertamanya soal lokasi, bukan teknik: validasi dilakukan tepat ketika data menyeberangi trust boundary — di method yang menerima parameter HTTP, di baris yang membaca berkas unggahan, di constructor yang menerima data dari layanan pihak ketiga. Alasannya bersifat arsitektural. Bila validasi tersebar di sepuluh tempat pemakaian data, satu tempat yang terlewat menjadi pintu masuk; bila terpusat di satu gerbang, data yang lolos dijamin bersih bagi seluruh kode di belakangnya. Validasi di titik masuk juga berarti fail fast: masukan tidak sah ditolak segera dengan pesan yang jelas, sebelum menular lebih jauh ke dalam sistem.
Prinsip kedua dari Bab sebelumnya berlaku penuh di sini: validasi bersifat fail closed. Masukan yang tidak bisa dibuktikan sah — bukan yang terbukti tidak sah — ditolak. Pikirkan validasi seperti petugas gerbang bandara: penumpang harus menunjukkan tiket yang sah, bukan petugas yang harus membuktikan penumpang tidak punya tiket.
Allowlist Karakter Panjang dan Rentang #
Ada dua filosofi memeriksa masukan. Blocklist mendaftar yang dilarang: tolak bila mengandung <, ', ../, dan seterusnya. Allowlist mendaftar yang diizinkan: terima bila seluruh karakter berasal dari himpunan kecil yang sah. Allowlist hampir selalu lebih kuat karena daftarnya selesai — kita tahu seluruh bentuk yang lolos — sedangkan blocklist selalu bisa dilewati oleh variasi baru: %3C adalah < hasil URL encoding, %2e%2e%2f adalah ../, dan daftar penyamaran itu tidak pernah habis. Program berikut menerapkan allowlist pada pendaftaran akun: nama pengguna 3-16 karakter dari huruf kecil dan angka, diawali huruf:
public class ValidasiPendaftaran {
// Hasil validasi selalu eksplisit: diterima/tidak beserta alasannya.
record Hasil(boolean diterima, String alasan) { }
public static void main(String[] args) {
System.out.println("=== Validasi nama pengguna (allowlist: a-z, 0-9, 3-16 karakter) ===");
String[] kandidatNama = {
"budi",
"siti123",
"ab",
"budiwijayasetyawan12345",
"Budi_Wijaya",
"budi wijaya",
"budi/../etc",
"budi<script>",
"budi;DROP",
"budi\n",
""
};
for (String nama : kandidatNama) {
Hasil hasil = validasiNamaPengguna(nama);
laporkan("[" + nama.replace("\n", "\\n") + "]", hasil);
}
System.out.println();
System.out.println("=== Validasi umur (parse-don't-validate + rentang 0-120) ===");
String[] kandidatUmur = {"25", "0", "121", "-5", "25.5", "dua puluh", "2147483648"};
for (String teks : kandidatUmur) {
Hasil hasil = validasiUmur(teks);
laporkan("[" + teks + "]", hasil);
}
}
static void laporkan(String input, Hasil hasil) {
System.out.printf("%-38s -> %s (%s)%n",
input, hasil.diterima() ? "DITERIMA" : "DITOLAK ", hasil.alasan());
}
// Allowlist: hanya kombinasi karakter yang diizinkan yang lolos.
static Hasil validasiNamaPengguna(String nama) {
if (nama == null) {
return new Hasil(false, "nama tidak boleh kosong");
}
if (nama.length() < 3 || nama.length() > 16) {
return new Hasil(false, "panjang harus 3-16 karakter, aktual " + nama.length());
}
if (!nama.matches("[a-z][a-z0-9]*")) {
return new Hasil(false, "hanya huruf kecil a-z dan angka, diawali huruf");
}
return new Hasil(true, "memenuhi semua aturan");
}
// Parse, don't validate: ubah dulu ke tipe data yang tepat,
// baru periksa rentang nilainya. Teks yang bukan bilangan ditolak mentah.
static Hasil validasiUmur(String teks) {
if (teks == null || teks.isBlank()) {
return new Hasil(false, "umur tidak boleh kosong");
}
int umur;
try {
umur = Integer.parseInt(teks.trim());
} catch (NumberFormatException e) {
return new Hasil(false, "bukan bilangan bulat yang valid");
}
if (umur < 0 || umur > 120) {
return new Hasil(false, "di luar rentang 0-120, aktual " + umur);
}
return new Hasil(true, "memenuhi semua aturan");
}
}Output yang Dihasilkan:
=== Validasi nama pengguna (allowlist: a-z, 0-9, 3-16 karakter) ===
[budi] -> DITERIMA (memenuhi semua aturan)
[siti123] -> DITERIMA (memenuhi semua aturan)
[ab] -> DITOLAK (panjang harus 3-16 karakter, aktual 2)
[budiwijayasetyawan12345] -> DITOLAK (panjang harus 3-16 karakter, aktual 23)
[Budi_Wijaya] -> DITOLAK (hanya huruf kecil a-z dan angka, diawali huruf)
[budi wijaya] -> DITOLAK (hanya huruf kecil a-z dan angka, diawali huruf)
[budi/../etc] -> DITOLAK (hanya huruf kecil a-z dan angka, diawali huruf)
[budi<script>] -> DITOLAK (hanya huruf kecil a-z dan angka, diawali huruf)
[budi;DROP] -> DITOLAK (hanya huruf kecil a-z dan angka, diawali huruf)
[budi\n] -> DITOLAK (hanya huruf kecil a-z dan angka, diawali huruf)
[] -> DITOLAK (panjang harus 3-16 karakter, aktual 0)
=== Validasi umur (parse-don't-validate + rentang 0-120) ===
[25] -> DITERIMA (memenuhi semua aturan)
[0] -> DITERIMA (memenuhi semua aturan)
[121] -> DITOLAK (di luar rentang 0-120, aktual 121)
[-5] -> DITOLAK (di luar rentang 0-120, aktual -5)
[25.5] -> DITOLAK (bukan bilangan bulat yang valid)
[dua puluh] -> DITOLAK (bukan bilangan bulat yang valid)
[2147483648] -> DITOLAK (bukan bilangan bulat yang valid)Sebelas kandidat nama diuji dan hanya dua yang lolos — sisanya ditolak dengan alasan spesifik. Perhatikan bahwa budi/../etc (upaya path traversal), budi<script> (upaya XSS), dan budi;DROP (upaya SQL injection) semuanya ditolak oleh satu aturan yang sama, tanpa kode khusus per jenis serangan. Kekuatan allowlist persis di situ: aturan yang mendeskripsikan bentuk sah otomatis menutup serangan yang belum kita bayangkan sekalipun.
Bedah baris-per-baris untuk method validasiNamaPengguna:
- Baris 42-44 (
if (nama == null)): pemeriksaan null selalu pertama — aksesnama.length()pada null melemparNullPointerExceptionsebelum aturan lain sempat bicara. Data yang bahkan bukan string tidak perlu diperiksa lebih jauh. - Baris 45-47 (batas panjang): pemeriksaan panjang dilakukan sebelum regex. Panjang adalah properti termurah untuk diperiksa, dan membatasinya sekaligus memotong serangan memori — string sepanjang satu juta karakter gagal di sini tanpa pernah mencapai
matches(). - Baris 48-50 (
nama.matches("[a-z][a-z0-9]*")): pola regex adalah allowlist dalam bentuk paling padat — huruf kecil di posisi pertama, lalu huruf kecil atau angka berulang. Panggilanmatches()mencocokkan seluruh string, bukan potongan, sehingga tidak ada karakter yang lolos di pinggiran. - Baris 51 (
return new Hasil(true, ...)): keberhasilan dikembalikan hanya setelah seluruh pemeriksaan lulus — pola fail closed dari Bab sebelumnya dalam bentuk paling murni.
Parse Don’t Validate #
Bagian kedua output berasal dari validasiUmur, dan pendekatannya berbeda secara filosofis. Alih-alih memeriksa bentuk teks dari bilangan (“apakah semua karakternya digit?”), method ini langsung mem-parse teks menjadi int lewat Integer.parseInt, lalu memeriksa rentang nilainya. Pendekatan parse, don’t validate ini punya dua keunggulan. Pertama, hasil parse adalah tipe data yang benar — int — yang bisa langsung dipakai logika bisnis tanpa konversi ulang. Kedua, pemeriksaan rentang menjadi jelas dan lengkap: -5 dan 121 ditolak karena berada di luar batas manusiawi, bukan karena bentuk teksnya salah.
Output membuktikan kasus-kasus yang kerap lolos dari validasi bentuk manual: "25.5" gagal di parse (desimal bukan int), "dua puluh" gagal di parse, dan "2147483648" gagal karena melampaui Integer.MAX_VALUE — parser yang menolak meluap akan menangkap input yang lolos dari pemeriksaan “semua karakter digit”. Exception NumberFormatException di sini bukan kegagalan sistem melainkan mekanisme penolakan: teks yang tidak bisa menjadi bilangan tidak pernah layak jadi bilangan.
Menolak atau Menyanitasi #
Kapan masukan ditolak mentah, kapan dibersihkan lalu diterima? Panduannya bergantung pada makna data:
| Jenis data | Pendekatan tepat | Alasan |
|---|---|---|
| Nama pengguna, kode produk, ID | Tolak bila tidak sesuai allowlist | Bentuk sah sempit; data menyimpang menandakan bug atau serangan |
| Bilangan, tanggal, enum | Parse ke tipe; tolak bila gagal | Tipe yang salah tidak boleh diteruskan |
| Nama tampilan, komentar, catatan | Terima longgar, lakukan encoding saat ditampilkan | Konten bebas perlu hidup; risikonya bukan saat disimpan melainkan saat dirender |
| Nama berkas dari pengguna | Validasi ketat + normalisasi path | Jalur relatif bisa keluar direktori (Bab Path Traversal) |
Pola pada baris ketiga itu penting: komentar pengguna yang berisi karakter < adalah data sah — yang salah adalah menempelkannya mentah ke halaman HTML. Pembersihan pada konteks tampilan adalah tugas output encoding, bab berikutnya, bukan tugas validasi masukan. Mencampur keduanya — mencoba “menyanitasi” masukan bebas dengan menebak ke mana data akan keluar — adalah sumber klasik celah keamanan, karena data yang sama butuh perlakuan berbeda untuk HTML, JavaScript, URL, dan SQL.
Validasi masukan menutup gerbang depan; sekarang data yang sah pun harus aman saat keluar dari aplikasi. Bab berikutnya, Output Encoding, menangani sisi yang berlawanan: mengubah data agar aman di tujuannya — entah halaman HTML, URL, atau perintah SQL.
Referensi #
- OWASP Input Validation Cheat Sheet
- OWASP SQL Injection Prevention Cheat Sheet
- Oracle: dev.java Security Trail
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ValidasiPendaftaran.java
Unduh berkaspublic class ValidasiPendaftaran {
// Hasil validasi selalu eksplisit: diterima/tidak beserta alasannya.
record Hasil(boolean diterima, String alasan) { }
public static void main(String[] args) {
System.out.println("=== Validasi nama pengguna (allowlist: a-z, 0-9, 3-16 karakter) ===");
String[] kandidatNama = {
"budi",
"siti123",
"ab",
"budiwijayasetyawan12345",
"Budi_Wijaya",
"budi wijaya",
"budi/../etc",
"budi<script>",
"budi;DROP",
"budi\n",
""
};
for (String nama : kandidatNama) {
Hasil hasil = validasiNamaPengguna(nama);
laporkan("[" + nama.replace("\n", "\\n") + "]", hasil);
}
System.out.println();
System.out.println("=== Validasi umur (parse-don't-validate + rentang 0-120) ===");
String[] kandidatUmur = {"25", "0", "121", "-5", "25.5", "dua puluh", "2147483648"};
for (String teks : kandidatUmur) {
Hasil hasil = validasiUmur(teks);
laporkan("[" + teks + "]", hasil);
}
}
static void laporkan(String input, Hasil hasil) {
System.out.printf("%-38s -> %s (%s)%n",
input, hasil.diterima() ? "DITERIMA" : "DITOLAK ", hasil.alasan());
}
// Allowlist: hanya kombinasi karakter yang diizinkan yang lolos.
static Hasil validasiNamaPengguna(String nama) {
if (nama == null) {
return new Hasil(false, "nama tidak boleh kosong");
}
if (nama.length() < 3 || nama.length() > 16) {
return new Hasil(false, "panjang harus 3-16 karakter, aktual " + nama.length());
}
if (!nama.matches("[a-z][a-z0-9]*")) {
return new Hasil(false, "hanya huruf kecil a-z dan angka, diawali huruf");
}
return new Hasil(true, "memenuhi semua aturan");
}
// Parse, don't validate: ubah dulu ke tipe data yang tepat,
// baru periksa rentang nilainya. Teks yang bukan bilangan ditolak mentah.
static Hasil validasiUmur(String teks) {
if (teks == null || teks.isBlank()) {
return new Hasil(false, "umur tidak boleh kosong");
}
int umur;
try {
umur = Integer.parseInt(teks.trim());
} catch (NumberFormatException e) {
return new Hasil(false, "bukan bilangan bulat yang valid");
}
if (umur < 0 || umur > 120) {
return new Hasil(false, "di luar rentang 0-120, aktual " + umur);
}
return new Hasil(true, "memenuhi semua aturan");
}
}