Setiap data yang masuk dari luar sistem—form HTTP, parameter URL, header, atau file upload—merupakan sumber potensi kerentanan. Aplikasi yang mempercayai input mentah membuka peluang bagi serangan SQL injection, command injection, dan path traversal. Validasi input pada boundary adalah pertahanan pertama yang efektif bila dilakukan dengan benar. Pada chapter ini kita akan mempraktikkan empat teknik inti: validasi berbasis whitelist dengan regex, parsing tipe-aman menggunakan Result, pencegahan SQL injection melalui parameterized query, serta pencegahan command injection dengan daftar argumen terpisah.
Validasi di Boundary dengan Whitelist #
Whitelist adalah daftar karakter atau pola yang diizinkan; semua karakter lain ditolak. Kebalikannya, blacklist mendaftar karakter terlarang, namun rawan terlewat karena sulit mengantisipasi semua vektor serangan. Regex berikut hanya menerima huruf, angka, dan garis bawah dengan panjang 3–32 karakter—cukup ketat untuk identifier seperti username maupun nama tabel.
val ID_PATTERN: Pattern = Pattern.compile("^[a-zA-Z0-9_]{3,32}$")
fun isValidIdentifier(input: String): Boolean = ID_PATTERN.matcher(input).matches()Ekspresi ^...$ memastikan seluruh string diperiksa, bukan hanya sebagian. Tanpa anchor ^ dan $, input "budi';--" dapat lolos karena prefix-nya valid.
Parsing Tipe-Aman dengan Result #
Konversi string ke tipe numerik adalah operasi rawan kegagalan. Fungsi bawaan toIntOrNull() mengembalikan Int?, namun rentang nilai tetap perlu diperiksa secara eksplisit. Dengan membungkus hasil dalam Result<Int>, pemanggil memaksa untuk menangani kegagalan secara sadar.
fun parsePort(raw: String): Result<Int> {
val n = raw.toIntOrNull() ?: return Result.failure(IllegalArgumentException("bukan angka"))
if (n !in 1..65535) return Result.failure(IllegalArgumentException("port di luar rentang 1..65535"))
return Result.success(n)
}Pola Result.failure(...) memberi tahu pemanggil bahwa operasi mungkin gagal dan harus ditangani. Ini lebih aman daripada melempar exception langsung di boundary karena exception tidak tertangkap sering kali menghentikan seluruh request handler.
Pencegahan SQL Injection #
SQL injection terjadi saat input pengguna disisipkan langsung ke string SQL. Bahkan placeholder ? pada klausa ORDER BY tidak aman karena kolom ORDER BY tidak dapat diparameterisasi. Solusinya adalah whitelist nama kolom yang diizinkan, dengan fallback aman bila input tidak cocok apa pun.
// input_validation.kt
// Validate at boundary. Whitelist not blacklist. Type-safe parsing.
// Demonstrate SQL/HTML/Command injection prevention patterns.
import java.util.regex.Pattern
// --- Whitelist validator for safe identifiers ---
val ID_PATTERN: Pattern = Pattern.compile("^[a-zA-Z0-9_]{3,32}$")
fun isValidIdentifier(input: String): Boolean = ID_PATTERN.matcher(input).matches()
// --- Type-safe parsing: convert string to Int with bounded range ---
fun parsePort(raw: String): Result<Int> {
val n = raw.toIntOrNull() ?: return Result.failure(IllegalArgumentException("bukan angka"))
if (n !in 1..65535) return Result.failure(IllegalArgumentException("port di luar rentang 1..65535"))
return Result.success(n)
}
// --- Whitelist for sort column names (defense against SQL injection via ORDER BY) ---
val ALLOWED_SORT_COLUMNS: Set<String> = setOf("id", "nama", "created_at", "updated_at")
fun safeOrderBy(userInput: String): String {
// Map user-friendly name to actual column; reject unknown names.
val kolom = ALLOWED_SORT_COLUMNS.firstOrNull { it.equals(userInput, ignoreCase = true) }
?: return "created_at" // fail-secure fallback
return kolom
}
// --- Build parameterized WHERE IN clause safely ---
fun buildInClausePlaceholder(count: Int): String {
require(count in 1..1000) { "count di luar rentang aman" }
return List(count) { "?" }.joinToString(prefix = "(", postfix = ")", separator = ", ")
}
// --- Email validation: whitelist format, not blacklist characters ---
val EMAIL_PATTERN: Pattern = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,32}$")
fun isValidEmail(email: String): Boolean = EMAIL_PATTERN.matcher(email).matches()
// --- Command injection prevention: use argument list, never shell string ---
fun safeCommandTransform(userInput: String): List<String> {
// Whitelist accepted characters; reject anything else.
if (!userInput.all { it.isLetterOrDigit() || it == '_' || it == '-' || it == '.' }) {
return emptyList()
}
// Argument list approach: each entry is a separate argument, no shell parsing.
return listOf("convert", "--input", userInput, "--output", "/tmp/out")
}
fun main() {
// Identifier whitelist demo
println("isValidIdentifier('budi_2026') = ${isValidIdentifier("budi_2026")}")
println("isValidIdentifier(\"budi';--\") = ${isValidIdentifier("budi';--")}")
println("isValidIdentifier('a b') = ${isValidIdentifier("a b")}")
println("---")
// Port parsing
listOf("8080", "70000", "abc", "443").forEach { p ->
val r = parsePort(p)
println("parsePort($p) = ${r.getOrElse { it.message ?: "error" }}")
}
println("---")
// Safe ORDER BY
listOf("nama", "password; DROP TABLE users;--", "created_at", "unknown").forEach { s ->
println("safeOrderBy($s) = ${safeOrderBy(s)}")
}
println("---")
// Safe IN clause
println("buildInClausePlaceholder(3) = ${buildInClausePlaceholder(3)}")
println("---")
// Email validation
listOf("[email protected]", "budi@@example", "siti@localhost", "andi@example").forEach { e ->
println("isValidEmail($e) = ${isValidEmail(e)}")
}
println("---")
// Safe command arguments
println("safeCommandTransform('foto.jpg') = ${safeCommandTransform("foto.jpg")}")
println("safeCommandTransform('a; rm -rf /') = ${safeCommandTransform("a; rm -rf /")}")
}Output yang Dihasilkan:
isValidIdentifier('budi_2026') = true
isValidIdentifier("budi';--") = false
isValidIdentifier('a b') = false
---
parsePort(8080) = 8080
parsePort(70000) = port di luar rentang 1..65535
parsePort(abc) = bukan angka
parsePort(443) = 443
---
safeOrderBy(nama) = nama
safeOrderBy(password; DROP TABLE users;--) = created_at
safeOrderBy(created_at) = created_at
safeOrderBy(unknown) = created_at
---
buildInClausePlaceholder(3) = (?, ?, ?)
---
isValidEmail([email protected]) = true
isValidEmail(budi@@example) = false
isValidEmail(siti@localhost) = false
isValidEmail(andi@example) = false
---
safeCommandTransform('foto.jpg') = [convert, --input, foto.jpg, --output, /tmp/out]
safeCommandTransform('a; rm -rf /') = []Mari kita bedah bagian-bagian kritis dari kode di atas:
ALLOWED_SORT_COLUMNS: himpunan kolom sah yang dapat dipakai padaORDER BY. FungsisafeOrderBymencocokkan input pengguna terhadap himpunan ini secara case-insensitive dan mengembalikancreated_atsebagai default aman bila tidak ada kecocokan.buildInClausePlaceholder: membangun string?,?,?untuk klausaIN. Placeholder dipisahkan dengan koma dan dikelilingi tanda kurung. Nilai aktual tetap dikirim melalui prepared statement, bukan disisipkan ke string SQL.require(count in 1..1000): membatasi jumlah placeholder untuk mencegah serangan denial-of-service melalui klausaINyang sangat panjang.EMAIL_PATTERN: regex email ketat. Tanda\\.di-escape agar hanya berfungsi sebagai literal titik, bukan wildcard regex. Panjang TLD dibatasi 2–32 karakter.safeCommandTransform: membangun daftar argumen, bukan satu string shell. Karena inputa; rm -rf /mengandung karakter yang tidak diizinkan, fungsi mengembalikan daftar kosong sebagai tanda penolakan.
Pencegahan Command Injection #
Command injection terjadi saat input pengguna dijadikan bagian dari perintah shell. Penyisipan karakter ;, |, atau ` memungkinkan penyerang menjalankan perintah terpisah. Dua lapis pertahanan: pertama, whitelist karakter input; kedua, gunakan daftar argumen terpisah daripada satu string shell.
// BAHAYA: input pengguna disisipkan ke string shell
// Runtime.getRuntime().exec("convert --input $userInput --output /tmp/out")Saat memakai ProcessBuilder atau Runtime.exec dengan List<String>, setiap argumen diperlakukan sebagai literal—tidak ada pemrosesan shell. Walaupun begitu, validasi karakter tetap dilakukan sebelum daftar argumen dibentuk.
Validasi dengan Tipe Domain #
Selain regex dan parsing numerik, validasi tipe domain memanfaatkan sistem tipe Kotlin. data class dengan field val dan batasan konstruktor mendorong pemanggil untuk membuat objek dalam keadaan valid sejak awal.
data class Port(val value: Int) {
init {
require(value in 1..65535) { "port di luar rentang" }
}
}Dengan pendekatan ini, kode bisnis tidak perlu lagi memvalidasi; objek Port sudah pasti berisi nilai sah. Validasi hanya terjadi di boundary—saat data masuk dari luar dan dikonversi ke tipe domain.
Referensi Lanjutan #
- OWASP Attacks — katalog vektor serangan OWASP untuk memahami risiko validasi input yang lemah
Penutup #
Validasi input yang efektif menggabungkan whitelist, parsing tipe-aman, dan penggunaan API yang memisahkan struktur perintah dari data pengguna. Pada chapter berikutnya kita akan membahas output encoding: bagaimana data yang sudah aman di dalam sistem tetap perlu di-encode saat dikirim ke browser atau lapisan presentasi lain untuk mencegah serangan cross-site scripting.