Chapter sebelumnya menutup dengan janji: ada satu teknik yang memutus mekanisme SQL injection sejak akarnya, bukan sekadar menyaring karakternya. Teknik itu adalah prepared statement — pola kerja yang memisahkan struktur SQL dari data secara fisik. Struktur didefinisikan sekali dengan placeholder ?, lalu nilai dikirim terpisah pada tiap eksekusi; server tidak pernah menyusun ulang kalimatnya, sehingga tanda kutip dan titik koma dalam input pengguna kehilangan kekuatan sebagai kode. Pada chapter ini kita membangun siklus lengkapnya di level SQL — PREPARE, EXECUTE, DEALLOCATE — membuktikan dua eksekusi beruntun dengan parameter berbeda, lalu mengirim payload injeksi melalui placeholder dan menyaksikan Empty set yang damai. Kita tutup dengan batas-batasnya: statement mana yang bisa diprepare, dan apa yang tidak boleh diparameterisasi.

Menyiapkan Data Uji #

Data produk yang sama kembali kita pakai — lima baris yang sudah cukup untuk semua skenario:

sql
DROP DATABASE IF EXISTS belajar_aman;
CREATE DATABASE belajar_aman;
USE belajar_aman;

CREATE TABLE produk (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  kategori VARCHAR(30) NOT NULL,
  harga DECIMAL(10,2) NOT NULL,
  stok INT NOT NULL
);

INSERT INTO produk (nama, kategori, harga, stok) VALUES
  ('Kopi Arabika Gayo', 'Minuman', 85000.00, 40),
  ('Teh Melati Premium', 'Minuman', 45000.00, 120),
  ('Gula Aren Cair', 'Bahan', 32000.00, 75),
  ('Susu Kental Manis', 'Bahan', 18000.00, 200),
  ('Sirup Jeruk', 'Minuman', 27000.00, 0);

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected (0.00 sec)

Query OK, 5 rows affected (0.00 sec)
Records: 5  Duplicates: 0  Warnings: 0

Siklus Dasar: PREPARE, EXECUTE, DEALLOCATE #

Tiga statement membentuk satu siklus hidup. Kita jalankan lengkap dalam satu rangkaian — latihannya mencari produk berdasarkan id, dua kali dengan nilai berbeda:

sql
PREPARE stmt FROM 'SELECT id, nama, harga FROM produk WHERE id = ?';

SET @id = 2;
EXECUTE stmt USING @id;

SET @id = 4;
EXECUTE stmt USING @id;

DEALLOCATE PREPARE stmt;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)
Statement prepared

Query OK, 0 rows affected (0.00 sec)

+----+--------------------+----------+
| id | nama               | harga    |
+----+--------------------+----------+
|  2 | Teh Melati Premium | 45000.00 |
+----+--------------------+----------+
1 row in set (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

+----+-------------------+----------+
| id | nama              | harga    |
+----+-------------------+----------+
|  4 | Susu Kental Manis | 18000.00 |
+----+-------------------+----------+
1 row in set (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Mari kita bedah baris demi baris. Baris PREPARE stmt FROM '...' mengirim teks SQL lengkap dengan placeholder ? ke server; server mem-parsing-nya sekali dan menyimpan hasilnya dengan nama stmt. Dua pasang SET @id = ... mengisi user variable — wadah nilai milik sesi. EXECUTE stmt USING @id menjalankan statement tersimpan dengan nilai @id mengisi posisi ? — perhatikan kedua eksekusi menghasilkan baris yang berbeda (Teh Melati Premium untuk id 2, Susu Kental Manis untuk id 4) meskipun teks SQL-nya tidak pernah berubah. Terakhir, DEALLOCATE PREPARE stmt melepas statement dari memori server — kebersihan yang penting sebelum sesi panjang menumpuk pulihan statement tak terpakai.

Beberapa Parameter Sekaligus #

Placeholder boleh lebih dari satu — urutan USING mengikuti urutan kemunculan ?:

sql
PREPARE cari FROM 'SELECT id, nama, harga FROM produk WHERE kategori = ? AND harga < ? ORDER BY harga';

SET @kat = 'Minuman';
SET @batas = 50000.00;
EXECUTE cari USING @kat, @batas;

DEALLOCATE PREPARE cari;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)
Statement prepared

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

+----+--------------------+----------+
| id | nama               | harga    |
+----+--------------------+----------+
|  5 | Sirup Jeruk        | 27000.00 |
|  2 | Teh Melati Premium | 45000.00 |
+----+--------------------+----------+
2 rows in set (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Hasilnya dua produk minuman di bawah lima puluh ribu rupiah: Sirup Jeruk (27000.00) dan Teh Melati Premium (45000.00) — urutan ORDER BY harga bekerja normal karena bagian struktur query tetap milik teks SQL, bukan parameter. Pola dua parameter ini persis seperti query filter di aplikasi nyata: kategori dari dropdown, batas harga dari form — dua nilai yang berasal dari input pengguna dan tidak boleh disambungkan sebagai string.

Payload Injeksi sebagai Data Murni #

Sekarang momen pembuktian yang dijanjikan chapter sebelumnya. Kita siapkan statement pencarian nama dengan satu placeholder, lalu kirim payload injeksi klasik ' OR '1'='1 sebagai nilai parameter:

sql
PREPARE cari_nama FROM 'SELECT id, nama FROM produk WHERE nama = ?';

SET @input = ''' OR ''1''=''1';
EXECUTE cari_nama USING @input;

DEALLOCATE PREPARE cari_nama;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)
Statement prepared

Query OK, 0 rows affected (0.00 sec)

Empty set (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Hasilnya Empty set — bandingkan dengan query rentan di chapter SQL injection yang mengembalikan seluruh lima baris! Mari kita bedah mengapa. Saat PREPARE dijalankan, teks SELECT ... WHERE nama = ? sudah selesai diparsing: satu kondisi perbandingan string, satu lubang nilai. Payload ' OR '1'='1 masuk sebagai isi lubang itu — murni string yang harus sama dengan isi kolom nama. Tidak ada produk bernama ' OR '1'='1, jadi hasilnya kosong. Tanda kutip dalam input tidak lagi “menutup string” milik aplikasi karena memang tidak ada string milik aplikasi yang terbuka — struktur sudah terkunci sebelum nilai tiba. Inilah sebabnya prepared statement mematikan injeksi secara struktural, bukan dengan menyaring karakter.

Batas yang Jujur: DDL Bisa, USE Tidak #

Prepared statement bukan dunia serba bisa. Sebagian besar SELECT, INSERT, UPDATE, DELETE, bahkan sebagian DDL seperti CREATE TABLE dan ALTER TABLE bisa diprepare — kita buktikan DDL-nya:

sql
PREPARE buat FROM 'CREATE TABLE produk_arsip LIKE produk';

EXECUTE buat;

DEALLOCATE PREPARE buat;

SHOW TABLES LIKE 'produk_arsip';

DROP TABLE produk_arsip;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)
Statement prepared

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

+---------------------------------------+
| Tables_in_belajar_aman (produk_arsip) |
+---------------------------------------+
| produk_arsip                          |
+---------------------------------------+
1 row in set (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

EXECUTE menjalankan CREATE TABLE dengan sukses — produk_arsip terbentuk dengan struktur yang sama persis dengan produk, sebelum kita bersihkan lagi. Sebaliknya, statement yang mengatur konteks sesi tidak didukung:

sql
-- @error
PREPARE u FROM 'USE belajar_aman';

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1295 (HY000): This command is not supported in the prepared statement protocol yet
Bye

ERROR 1295 (HY000): This command is not supported in the prepared statement protocol yet — server menolaknya sejak awal. Keluarga statement administratif-sesi seperti USE, SET PASSWORD, atau LOCK TABLES memang berada di luar dunia prepare, dan daftar lengkapnya tercantum di dokumentasi resmi. Satu batas lain yang lebih halus tapi sama pentingnya: placeholder hanya boleh menempati posisi nilai. Nama tabel, kolom, atau kata kunci ORDER BY tidak bisa diparameterisasi — ? di posisi identifier ditolak parsing. Kebutuhan “nama tabel dinamis” yang sah (misalnya sharding per bulan) harus ditangani dengan daftar putih nilai yang diizinkan, lalu disusun ke teks SQL — bukan dengan menerima nama tabel mentah dari input.

Prepared Statement di Aplikasi Nyata #

Dalam aplikasi, siklus PREPARE-EXECUTE biasanya tidak kita tulis sebagai SQL mentah — konektor modern menjalanakannya lewat placeholder dengan protokol biner di balik layar. PHP dengan mysqli:

php
<?php
// pola aplikasi: struktur sekali, nilai per eksekusi
$stmt = $mysqli->prepare(
    "SELECT id, nama, harga FROM produk WHERE kategori = ? AND harga < ?"
);
$stmt->bind_param("sd", $kategori, $batas);   // s = string, d = double

foreach (["Minuman" => 50000.00, "Bahan" => 40000.00] as $kategori => $batas) {
    $stmt->execute();
    $result = $stmt->get_result();
    // setiap eksekusi memakai plan yang sama, nilai berbeda
}

Dan Python dengan DB-API:

python
stmt = "SELECT id, nama, harga FROM produk WHERE kategori = %s AND harga < %s"
for kategori, batas in [("Minuman", 50000.00), ("Bahan", 40000.00)]:
    cursor.execute(stmt, (kategori, batas))
    rows = cursor.fetchall()

Perhatikan kedua contoh: teks SQL ditulis sekali sebagai konstanta, nilai datang dari variabel terpisah, dan eksekusi berulang memakai statement yang sama — persis pola PREPARE-EXECUTE yang kita praktikkan manual. Keuntungan keduanya: keamanan struktural (injeksi mati) dan efisiensi (parsing serta perencanaan eksekusi terjadi sekali, lalu rencananya dipakai ulang — sangat terasa pada query yang dijalankan ribuan kali dalam loop).

Membersihkan Latihan #

Tabel arsip sudah kita jatuhkan di tengah jalan; tinggal database-nya:

sql
DROP DATABASE IF EXISTS belajar_aman;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Referensi #

Perjalanan keamanan kita kini lengkap: akun dikenali lewat authentication, haknya dibatasi lewat privilege dan role, kualitas sandinya dijaga kebijakan password, dan aplikasinya terlindungi dari injeksi berkat prepared statement. Satu lapisan terakhir yang belum kita bahas adalah konfigurasi server itu sendiri — jaringan, TLS, dan file — yang menjadi chapter penutup section ini.