Setiap aplikasi yang menerima input pengguna dan menyusunnya ke dalam SQL berisiko terkena SQL injection — serangan yang menyusupkan kode SQL berbahaya melalui input. Cedera akibat SQL injection beragam: kebocoran data, otentikasi yang dilewati, hingga penghapusan tabel. JDBC menyediakan PreparedStatement sebagai mekanisme standar untuk mengeksekusi SQL dengan parameter dengan aman. Pada chapter ini kita akan mempelajari cara kerja PreparedStatement, parameter binding, dan demonstrasi langsung bagaimana input berbahaya gagal menembus query berparameter.
Apa itu PreparedStatement #
PreparedStatement adalah versi Statement yang SQL-nya sudah dikompilasi (di- pre-compile) oleh database sebelum parameter diisi. SQL ditulis dengan placeholder ? yang akan diganti nilai saat runtime. Karena struktur SQL sudah ditetapkan saat pre-compile, database memperlakukan parameter sebagai literal value, bukan sebagai bagian dari sintaks SQL. Inilah yang membuat SQL injection gagal.
val sql = "INSERT INTO pengguna(nama, email) VALUES(?, ?)"
conn.prepareStatement(sql).use { ps ->
ps.setString(1, "Budi Santoso")
ps.setString(2, "[email protected]")
ps.executeUpdate()
}Index parameter dimulai dari 1 (bukan 0), mengikuti konvensi JDBC yang diwarisi dari ODBC.
Parameter Binding dan Tipe Data #
Method setXxx(index, value) mengikat nilai ke placeholder sesuai tipe data:
| Method | Tipe Kotlin/Java | Tipe SQL umum |
|---|---|---|
setString(i, s) | String | VARCHAR, TEXT |
setInt(i, n) | Int | INTEGER |
setLong(i, n) | Long | BIGINT |
setBoolean(i, b) | Boolean | BOOLEAN, TINYINT |
setDouble(i, d) | Double | DOUBLE, REAL |
setNull(i, type) | null | tipe SQL apa pun |
setObject(i, obj) | tipe apa pun | binding generik |
Database akan melakukan type coercion yang aman dan menangani escaping otomatis — tanda kutip, titik koma, dan karakter khusus di nilai dianggap literal, bukan sintaks SQL.
Mencegah SQL Injection #
Misalkan kita punya form login yang meminta email, lalu SQL disusun dengan string concatenation:
val email = "x' OR '1'='1"
val sql = "SELECT * FROM pengguna WHERE email = '$email'"
// Menjadi: SELECT * FROM pengguna WHERE email = 'x' OR '1'='1'
// -> Mengembalikan SEMUA penggunaDengan PreparedStatement, input x' OR '1'='1 dianggap sebagai nilai string biasa untuk kolom email. Tidak ada satu pun pengguna yang cocok dengan email x' OR '1'='1, jadi query mengembalikan 0 baris — persis seperti yang diharapkan.
Kode Lengkap: Binding, Batch, dan Kontras #
Kode berikut membuat tabel pengguna, menyisipkan beberapa entri via PreparedStatement, mendemonstrasikan percobaan SQL injection yang gagal, dan menampilkan batch insert untuk efisiensi.
// prepared_statements.kt
// Mendemonstrasikan PreparedStatement: parameter binding, SQL injection
// prevention, dan batch insert. Memakai SQLite via JDBC.
import java.sql.Connection
import java.sql.DriverManager
data class Pengguna(val id: Int, val nama: String, val email: String)
fun main() {
Class.forName("org.sqlite.JDBC")
val url = "jdbc:sqlite:file::memory:?cache=shared"
val keeper: Connection = DriverManager.getConnection(url)
DriverManager.getConnection(url).use { conn ->
conn.createStatement().use { stmt ->
stmt.execute(
"""CREATE TABLE pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)"""
)
}
// 1. PreparedStatement INSERT dengan binding parameter
println("=== PreparedStatement INSERT ===")
val insertSql = "INSERT INTO pengguna(nama, email) VALUES(?, ?)"
conn.prepareStatement(insertSql).use { ps ->
ps.setString(1, "Budi Santoso")
ps.setString(2, "[email protected]")
println("Executed: ${ps.executeUpdate()} row")
ps.setString(1, "Siti Aminah")
ps.setString(2, "[email protected]")
println("Executed: ${ps.executeUpdate()} row")
ps.setString(1, "Andi Wijaya")
ps.setString(2, "[email protected]")
println("Executed: ${ps.executeUpdate()} row")
}
// 2. SQL injection demonstration: user input yang berbahaya
println("\n=== SQL injection demo (SAFE karena PreparedStatement) ===")
val maliciousInput = "hacker' OR '1'='1"
conn.prepareStatement("SELECT COUNT(*) FROM pengguna WHERE email = ?").use { ps ->
ps.setString(1, maliciousInput)
ps.executeQuery().use { rs ->
if (rs.next()) println("Hasil query untuk '$maliciousInput': ${rs.getInt(1)} baris")
}
}
// 3. SELECT dengan parameter ID
println("\n=== PreparedStatement SELECT by ID ===")
conn.prepareStatement("SELECT id, nama, email FROM pengguna WHERE id = ?").use { ps ->
ps.setInt(1, 2)
ps.executeQuery().use { rs ->
if (rs.next()) {
val p = Pengguna(rs.getInt("id"), rs.getString("nama"), rs.getString("email"))
println("Ditemukan: $p")
} else println("Tidak ditemukan")
}
}
// 4. Batch insert: eksekusi banyak INSERT dalam satu round-trip
println("\n=== Batch INSERT (5 baris sekaligus) ===")
conn.prepareStatement("INSERT INTO pengguna(nama, email) VALUES(?, ?)").use { ps ->
val data = listOf(
"Dewi Lestari" to "[email protected]",
"Eko Prasetyo" to "[email protected]",
"Fajar Nugroho" to "[email protected]",
"Gita Saraswati" to "[email protected]",
"Hadi Kusuma" to "[email protected]"
)
for ((nama, email) in data) {
ps.setString(1, nama)
ps.setString(2, email)
ps.addBatch()
}
val counts = ps.executeBatch()
println("Baris ditambahkan per batch entry: ${counts.toList()}")
}
// 5. Verifikasi total
println("\n=== Verifikasi total pengguna ===")
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT COUNT(*) FROM pengguna").use { rs ->
if (rs.next()) println("Total: ${rs.getInt(1)} pengguna")
}
}
// 6. Kontras: Statement rentan SQL injection
println("\n=== Kontras: Statement rentan SQL injection ===")
println("Statement: \"SELECT * FROM pengguna WHERE email = '\" + userInput + \"'\"")
println("Bila userInput = \"x' OR '1'='1\", query menjadi:")
println(" SELECT * FROM pengguna WHERE email = 'x' OR '1'='1'")
println(" -> mengembalikan SEMUA pengguna. Bahaya kebocoran data.")
}
keeper.close()
}Output yang Dihasilkan:
=== PreparedStatement INSERT ===
Executed: 1 row
Executed: 1 row
Executed: 1 row
=== SQL injection demo (SAFE karena PreparedStatement) ===
Hasil query untuk 'hacker' OR '1'='1': 0 baris
=== PreparedStatement SELECT by ID ===
Ditemukan: Pengguna(id=2, nama=Siti Aminah, [email protected])
=== Batch INSERT (5 baris sekaligus) ===
Baris ditambahkan per batch entry: [1, 1, 1, 1, 1]
=== Verifikasi total pengguna ===
Total: 8 pengguna
=== Kontras: Statement rentan SQL injection ===
Statement: "SELECT * FROM pengguna WHERE email = '" + userInput + "'"
Bila userInput = "x' OR '1'='1", query menjadi:
SELECT * FROM pengguna WHERE email = 'x' OR '1'='1'
-> mengembalikan SEMUA pengguna. Bahaya kebocoran data.Mari kita telaah bagian-bagian kunci dari kode di atas:
INSERT INTO pengguna(nama, email) VALUES(?, ?)— SQL ditulis sekali, lalu dipakai berulang. Database meng- pre-compile dan menyimpan rencana eksekusi (query plan) sekali, sehingga eksekusi berikutnya lebih cepat daripadaStatement.execute(String)mentah.ps.setString(1, "Budi Santoso")— index 1 mengacu pada placeholder pertama. String dengan tanda kutip tunggal sepertiO'Brienakan otomatis di-escape menjadi'O''Brien'oleh driver.- Percobaan SQL injection — input
hacker' OR '1'='1dipakai sebagai nilai parameter. Hasil query adalah 0 baris karena tidak ada pengguna dengan emailhacker' OR '1'='1— karakter'dianggap literal, bukan penutup string. addBatch()+executeBatch()— menambah banyak operasi ke antrian, lalu mengeksekusinya sekaligus. Lebih efisien daripadaexecuteUpdate()berulang karena mengurangi round-trip jaringan ke database.
Referensi Lanjutan #
- java.sql package (Java SE 21)
— API reference
PreparedStatementuntuk query berparameter di paketjava.sql
Selanjutnya #
Kita sudah memakai executeQuery() yang mengembalikan ResultSet. Chapter berikutnya — Result Sets — akan membedah cara iterasi ResultSet, akses kolom via nama atau index, serta jenis cursor (forward-only vs scrollable).