Satu string yang diketik pengguna, satu tanda kutip, dan satu penggabungan teks — dari kombinasi sesederhana itulah SQL injection lahir: input yang seharusnya dibaca sebagai data berubah menjadi potongan kode SQL yang ikut dieksekusi database. Serangan ini sudah bertahun-tahun menempati daftar risiko keamanan aplikasi OWASP, dan sejarahnya dipenuhi insiden kebocoran data skala jutaan akun. Kabar baiknya: pertahanannya sudah matang dan tersedia langsung di JDBC. Chapter ini membedah anatomi serangannya sampai ke tanda kutipnya, lalu menyusun pertahanan berlapis — parameter terikat sebagai benteng utama, validasi masukan dan hak akses minimum sebagai lapisan cadangannya.
Anatomi Serangan #
Semua bermula dari pola penggabungan: "SELECT * FROM pengguna WHERE nama = '" + input + "'". Di dalam SQL, tanda kutip tunggal adalah pembatas literal teks; begitu input mengandung tanda kutip, pembatas itu tertutup lebih awal dan sisa input dibaca database sebagai kode. Beberapa bentuk muatan jahat (payload) yang paling sering muncul:
| Payload | Niat | Akibat pada SQL gabungan |
|---|---|---|
' OR '1'='1 | kondisi selalu benar | WHERE nama = '' OR '1'='1' — semua baris terbaca |
Budi'; DELETE FROM pesanan; -- | pernyataan beruntun | dua pernyataan terkirim jika opsi multi-statement aktif |
' UNION SELECT kartu, saldo FROM pembayaran -- | menggabung hasil | data tabel lain ikut terbaca |
'; -- | memotong sisa kueri | kondisi setelah -- menjadi komentar |
Perhatikan polanya: pengubahan kondisi, penyisipan pernyataan baru, dan penggabungan tabel lain semuanya memakai bahasa SQL yang sama dengan yang kita tulis. Database tidak punya cara membedakan bagian dari programmer dan bagian dari penyerang — keduanya sudah menyatu dalam satu string.
Pertahanan Berlapis #
Prinsip keamanan yang sehat tidak bertumpu pada satu tembok. Lapisan-lapisan berikut saling menguatkan:
| Lapisan | Peran | Status |
|---|---|---|
PreparedStatement + parameter ? | nilai tidak pernah masuk tahap parsing SQL | wajib, lapisan utama |
| Validasi whitelist masukan | input aneh ditolak sebelum menyentuh SQL | cadangan |
| Hak akses minimum (least privilege) | akun database hanya boleh operasi yang perlu | pembatas dampak |
| Pesan galat tanpa detail SQL | log internal, pesan umum ke pengguna | mencegah bocornya petunjuk struktur |
| Batas ukuran dan timeout query | mencegah payload raksasa dan query gantung | operasional |
Mengapa PreparedStatement menjadi lapisan utama? Karena mekanismenya struktural, bukan penyaringan: template SQL dikirim dan di-parse lebih dulu, nilai menyusul sebagai data melalui kanal terpisah. Tidak ada tanda kutip yang perlu di-escape, tidak ada pola jahat yang perlu ditebak — data tidak pernah menjadi kode.
Demo Serangan versus Pertahanan #
Program berikut menjalankan ketiga payload di atas melalui pembangun kueri yang rentan, membandingkannya dengan jalur berparameter, lalu menguji validasi whitelist. Seluruhnya berupa manipulasi string dan pencocokan nilai yang berjalan tanpa database, sehingga kita bisa melihat isi SQL yang sebenarnya terbentuk:
import java.util.List;
public class DemoInjection {
static final List<String> PENGGUNA = List.of("Budi", "Siti", "Dewi");
public static void main(String[] args) {
System.out.println("== 1. Kueri rentan: nilai pengguna digabung langsung ==");
tampilRawan("Budi");
tampilRawan("' OR '1'='1");
tampilRawan("Budi'; DELETE FROM pesanan; --");
System.out.println("== 2. Kueri berparameter: nilai terkirim sebagai data ==");
System.out.println("input : Budi -> baris cocok : "
+ jalankanBerparameter("Budi"));
System.out.println("input : ' OR '1'='1 -> baris cocok : "
+ jalankanBerparameter("' OR '1'='1"));
System.out.println();
System.out.println("== 3. Validasi whitelist sebelum menyentuh SQL ==");
System.out.println("input : Budi -> " + validasiWhitelist("Budi"));
System.out.println("input : ' OR '1'='1 -> " + validasiWhitelist("' OR '1'='1"));
System.out.println();
System.out.println("== 4. Prinsip hak akses minimum ==");
System.out.println("akun aplikasi : toko_app");
System.out.println("hak SQL : SELECT, INSERT, UPDATE pada tabel milik aplikasi");
System.out.println("tanpa hak : DROP, DELETE massal, akses tabel sistem");
}
/** Pola yang salah: struktur SQL dan data pengguna menyatu. */
static void tampilRawan(String input) {
String sql = "SELECT * FROM pengguna WHERE nama = '" + input + "'";
System.out.println("input : " + input);
System.out.println("SQL : " + sql);
if (input.contains(";")) {
System.out.println("efek : pernyataan tambahan dapat ikut tereksekusi"
+ " bila opsi multi-statement aktif");
} else if (input.contains(" OR ")) {
System.out.println("efek : kondisi WHERE dapat menjadi selalu benar,"
+ " seluruh baris terbaca");
} else if (input.contains("'")) {
System.out.println("efek : struktur literal SQL berubah");
}
System.out.println();
}
/** Pola yang benar: nilai hanya dibandingkan sebagai data. */
static int jalankanBerparameter(String nilai) {
int cocok = 0;
for (String p : PENGGUNA) {
if (p.equals(nilai)) {
cocok++;
}
}
return cocok;
}
/** Validasi masukan: hanya huruf dan spasi yang berarti di sini. */
static String validasiWhitelist(String input) {
return input.matches("[A-Za-z ]+")
? "LOLOS (pola ^[A-Za-z ]+$)"
: "DITOLAK (ada karakter di luar huruf dan spasi)";
}
}Output yang Dihasilkan:
== 1. Kueri rentan: nilai pengguna digabung langsung ==
input : Budi
SQL : SELECT * FROM pengguna WHERE nama = 'Budi'
input : ' OR '1'='1
SQL : SELECT * FROM pengguna WHERE nama = '' OR '1'='1'
efek : kondisi WHERE dapat menjadi selalu benar, seluruh baris terbaca
input : Budi'; DELETE FROM pesanan; --
SQL : SELECT * FROM pengguna WHERE nama = 'Budi'; DELETE FROM pesanan; --'
efek : pernyataan tambahan dapat ikut tereksekusi bila opsi multi-statement aktif
== 2. Kueri berparameter: nilai terkirim sebagai data ==
input : Budi -> baris cocok : 1
input : ' OR '1'='1 -> baris cocok : 0
== 3. Validasi whitelist sebelum menyentuh SQL ==
input : Budi -> LOLOS (pola ^[A-Za-z ]+$)
input : ' OR '1'='1 -> DITOLAK (ada karakter di luar huruf dan spasi)
== 4. Prinsip hak akses minimum ==
akun aplikasi : toko_app
hak SQL : SELECT, INSERT, UPDATE pada tabel milik aplikasi
tanpa hak : DROP, DELETE massal, akses tabel sistemMari kita telaah bukti pada output di atas:
- Payload
' OR '1'='1merusak klausaWHERE: SQL yang tercetak memuatnama = '' OR '1'='1'— literal nama berakhir di tanda kutip kedua, sisanya adalah kondisi OR yang bernilai benar untuk setiap baris. Bandingkan dengan inputBudiyang menghasilkan SQL normal: dari luar, keduanya hanya berbeda satu tanda kutip. - Payload beruntun menyisipkan
DELETE: tanda titik dua memisahkan pernyataan dan--menjadikan sisanya komentar. JDBC menolak multi-statement secara default, tetapi sebagian driver mengaktifkannya lewat opsi koneksi — alasan lapisan tambahan tetap diperlukan. jalankanBerparametermengembalikan1lalu0: jalur berparameter membandingkan nilai secara utuh terhadap isi kolom. Payload yang sama tidak menemukan satu baris pun — input jahat hanya menjadi string yang tidak cocok, bukan kode.- Whitelist menolak payload: pola
[A-Za-z ]+hanya menerima huruf dan spasi. Whitelist (mendaftar yang boleh) lebih kuat daripada blacklist (menebak yang berbahaya) karena daftar karakter berbahaya tidak pernah lengkap. - Hak akses
toko_app: kalau lapisan pertama sampai tertembus, akun aplikasi yang hanya memegangSELECT,INSERT,UPDATEmempersulitDROP TABLEdanUNIONke tabel lain — ruang gerak penyerang menyempit drastis.
Bagian SQL yang Tidak Bisa Dibuat Parameter #
PreparedStatement hanya mengikat nilai, bukan struktur. Nama tabel, nama kolom, arah ORDER BY, dan daftar pada IN (...) adalah bagian struktur — menaruhnya di ? tidak didukung SQL standar. Untuk bagian-bagian itu, gunakan whitelist di kode Anda:
// nilai dari pengguna hanya dipakai sebagai KUNCI pencarian, bukan digabung
private static final Set<String> KOLOM_BOLEH = Set.of("nama", "harga", "stok");
private static final Set<String> ARAH_BOLEH = Set.of("ASC", "DESC");
String kolom = KOLOM_BOLEH.contains(pilihan) ? pilihan : "nama";
String arah = ARAH_BOLEH.contains(urutan) ? urutan : "ASC";
String sql = "SELECT id, nama, harga FROM produk ORDER BY " + kolom + " " + arah
+ " LIMIT ? OFFSET ?";Untuk WHERE nama IN (...) dengan jumlah nilai yang berubah-ubah, bangun placeholder-nya secara programatik — "?,?,?" sebanyak kebutuhan — lalu ikat setiap nilai dengan setString. Struktur tetap ditulis oleh kode kita; data tetap terikat sebagai parameter.
Dengan nilai yang selalu terikat, struktur yang di-whitelist, dan akun database yang berhemat hak, aplikasi kita sudah sulit dibuka dari sisi SQL. Lapisan berikutnya berpindah ke sisi performa dan operasional: koneksi database mahal untuk dibuka berulang — chapter berikutnya memperkenalkan connection pooling dan DataSource.
Referensi #
- OWASP — SQL Injection Prevention Cheat Sheet
- OWASP — Top 10 Web Application Security Risks
- Oracle: PreparedStatement (JDK 25)
- dev.java — Security
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
DemoInjection.java
Unduh berkasimport java.util.List;
public class DemoInjection {
static final List<String> PENGGUNA = List.of("Budi", "Siti", "Dewi");
public static void main(String[] args) {
System.out.println("== 1. Kueri rentan: nilai pengguna digabung langsung ==");
tampilRawan("Budi");
tampilRawan("' OR '1'='1");
tampilRawan("Budi'; DELETE FROM pesanan; --");
System.out.println("== 2. Kueri berparameter: nilai terkirim sebagai data ==");
System.out.println("input : Budi -> baris cocok : "
+ jalankanBerparameter("Budi"));
System.out.println("input : ' OR '1'='1 -> baris cocok : "
+ jalankanBerparameter("' OR '1'='1"));
System.out.println();
System.out.println("== 3. Validasi whitelist sebelum menyentuh SQL ==");
System.out.println("input : Budi -> " + validasiWhitelist("Budi"));
System.out.println("input : ' OR '1'='1 -> " + validasiWhitelist("' OR '1'='1"));
System.out.println();
System.out.println("== 4. Prinsip hak akses minimum ==");
System.out.println("akun aplikasi : toko_app");
System.out.println("hak SQL : SELECT, INSERT, UPDATE pada tabel milik aplikasi");
System.out.println("tanpa hak : DROP, DELETE massal, akses tabel sistem");
}
/** Pola yang salah: struktur SQL dan data pengguna menyatu. */
static void tampilRawan(String input) {
String sql = "SELECT * FROM pengguna WHERE nama = '" + input + "'";
System.out.println("input : " + input);
System.out.println("SQL : " + sql);
if (input.contains(";")) {
System.out.println("efek : pernyataan tambahan dapat ikut tereksekusi"
+ " bila opsi multi-statement aktif");
} else if (input.contains(" OR ")) {
System.out.println("efek : kondisi WHERE dapat menjadi selalu benar,"
+ " seluruh baris terbaca");
} else if (input.contains("'")) {
System.out.println("efek : struktur literal SQL berubah");
}
System.out.println();
}
/** Pola yang benar: nilai hanya dibandingkan sebagai data. */
static int jalankanBerparameter(String nilai) {
int cocok = 0;
for (String p : PENGGUNA) {
if (p.equals(nilai)) {
cocok++;
}
}
return cocok;
}
/** Validasi masukan: hanya huruf dan spasi yang berarti di sini. */
static String validasiWhitelist(String input) {
return input.matches("[A-Za-z ]+")
? "LOLOS (pola ^[A-Za-z ]+$)"
: "DITOLAK (ada karakter di luar huruf dan spasi)";
}
}