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.

kotlin PreparedStatement dengan 2 parameter
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:

MethodTipe Kotlin/JavaTipe SQL umum
setString(i, s)StringVARCHAR, TEXT
setInt(i, n)IntINTEGER
setLong(i, n)LongBIGINT
setBoolean(i, b)BooleanBOOLEAN, TINYINT
setDouble(i, d)DoubleDOUBLE, REAL
setNull(i, type)nulltipe SQL apa pun
setObject(i, obj)tipe apa punbinding 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:

kotlin Rentan SQL injection
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 pengguna

Dengan 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.

kotlin
// 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:

text
=== 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:

  1. 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 daripada Statement.execute(String) mentah.
  2. ps.setString(1, "Budi Santoso") — index 1 mengacu pada placeholder pertama. String dengan tanda kutip tunggal seperti O'Brien akan otomatis di-escape menjadi 'O''Brien' oleh driver.
  3. Percobaan SQL injection — input hacker' OR '1'='1 dipakai sebagai nilai parameter. Hasil query adalah 0 baris karena tidak ada pengguna dengan email hacker' OR '1'='1 — karakter ' dianggap literal, bukan penutup string.
  4. addBatch() + executeBatch() — menambah banyak operasi ke antrian, lalu mengeksekusinya sekaligus. Lebih efisien daripada executeUpdate() berulang karena mengurangi round-trip jaringan ke database.

Referensi Lanjutan #

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).