Setiap aplikasi yang kita bangun pada bab-bab sebelumnya menerima masukan dari luar: judul tugas dari client, kata sandi dari pengguna, nomor halaman dari query string. Begitu masukan seperti itu menjadi bagian dari query database, satu pertanyaan hidup atau tidak: apakah nilainya dikirim sebagai data, atau dirangkai sebagai bagian dari kode SQL? Jawabannya menentukan apakah aplikasi kita aman dari SQL injection — celah keamanan yang telah mengganggu aplikasi web selama beberapa dekade. Chapter ini membuktikan risikonya lewat eksperimen langsung, lalu menegakkan solusinya: parameter binding pada node:sqlite, pg, dan mysql2.
Eksperimen Rentan vs Aman #
Kode berikut menyiapkan tabel pengguna lalu membandingkan dua function pencarian yang tujuannya sama: mencari pengguna berdasarkan nama. Function pertama merangkai masukan langsung ke string SQL; function kedua mengirim masukan sebagai parameter terpisah:
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE pengguna (
id INTEGER PRIMARY KEY,
nama TEXT NOT NULL,
sandi TEXT NOT NULL
)
`);
const sisip = db.prepare('INSERT INTO pengguna (nama, sandi) VALUES (?, ?)');
sisip.run('Budi', 'rahasia-budi');
sisip.run('Siti', 'rahasia-siti');
// Versi RENTAN: masukan dirangkai langsung ke string SQL.
function cariRentan(nama) {
const sql = `SELECT * FROM pengguna WHERE nama = '${nama}'`;
console.log('SQL yang terbentuk:', sql);
return db.prepare(sql).all();
}
// Versi AMAN: masukan dikirim sebagai parameter terpisah.
function cariAman(nama) {
return db.prepare('SELECT * FROM pengguna WHERE nama = ?').all(nama);
}
const serangan = "' OR '1'='1";
console.log('Hasil cariRentan(serangan):', JSON.stringify(cariRentan(serangan)));
console.log('Hasil cariAman(serangan) :', JSON.stringify(cariAman(serangan)));
console.log('Hasil cariAman("Siti") :', JSON.stringify(cariAman('Siti')));
db.close();Output yang Dihasilkan:
SQL yang terbentuk: SELECT * FROM pengguna WHERE nama = '' OR '1'='1'
Hasil cariRentan(serangan): [{"id":1,"nama":"Budi","sandi":"rahasia-budi"},{"id":2,"nama":"Siti","sandi":"rahasia-siti"}]
Hasil cariAman(serangan) : []
Hasil cariAman("Siti") : [{"id":2,"nama":"Siti","sandi":"rahasia-siti"}]Mari kita baca hasil eksperimen itu dengan cermat:
- Baris 18-22 (
cariRentan): template literal menyisipkannamaapa adanya ke dalam string SQL. Masukan normal sepertiSitimemang menghasilkan query yang wajar. - Baris 29 (
const serangan): string ini sengaja dibuat sebagai masukan jahat. Saat dirangkai, tanda kutip tunggal di dalamnya menutup string'...'di SQL lebih awal, laluOR '1'='1'menjadi kode SQL sungguhan yang selalu bernilai benar. - Baris 31 (hasil
cariRentan): query berubah makna menjadi “ambil semua pengguna” — kedua baris lengkap dengan kolomsandiikut terekspos. Di aplikasi nyata, varian serangan yang sama bisa menambahkan'; DROP TABLE ...atau panggilan administratif lain. - Baris 32 (hasil
cariAman): string serangan yang identik dikirim sebagai parameter, maka database memperlakukannya sebagai teks biasa yang dibandingkan dengan kolomnama. Tidak ada pengguna yang cocok, hasilnya array kosong. - Baris 33 (hasil
cariAman("Siti")): masukan normal tetap bekerja seperti harapan — keamanan tidak mengurangi fungsionalitas pencarian.
Mekanisme di Balik Parameter Binding #
Keunggulan parameter binding lahir dari arsitektur yang dirancang sejak awal, bukan dari trik tambahan. Saat db.prepare(sql) dipanggil, mesin database mengompilasi struktur query terlebih dahulu: tabel mana yang disentuh, kolom mana yang dibandingkan, operator apa yang dipakai. Setelah struktur terkunci, nilai parameter masuk lewat jalur terpisah sebagai data mentah — mesin tidak pernah memindai ulang nilai tersebut untuk mencari sintaks SQL. Perbandingannya seperti formulir cetak: kotak isian tidak bisa mengubah teks pertanyaan di atasnya. Konsekuensi praktisnya ada dua: query yang sama dapat dikompilasi sekali lalu dipakai berulang, dan tipe nilai tetap terjaga — angka terikat sebagai angka, bukan teks.
Setiap driver menandai placeholder dengan gaya masing-masing:
| Driver | Placeholder posisional | Placeholder bernama | Bentuk argumen |
|---|---|---|---|
node:sqlite | ? | :nama | Nilai berurutan, atau object |
pg | $1, $2, … | — | Array nilai berurutan |
mysql2 | ? | — | Array nilai berurutan |
Placeholder bernama node:sqlite memakai awalan titik dua pada SQL dan object sebagai argumen. Pola ini membuat query panjang jauh lebih terbaca karena kita tidak perlu menghitung urutan tanda tanya:
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE kota (
id INTEGER PRIMARY KEY,
nama TEXT NOT NULL,
provinsi TEXT NOT NULL
)
`);
// Placeholder bernama memakai awalan titik dua dan object sebagai argumen.
const sisip = db.prepare(
'INSERT INTO kota (nama, provinsi) VALUES (:nama, :provinsi)'
);
sisip.run({ nama: 'Bandung', provinsi: 'Jawa Barat' });
sisip.run({ nama: 'Bogor', provinsi: 'Jawa Barat' });
sisip.run({ nama: 'Surabaya', provinsi: 'Jawa Timur' });
const cari = db.prepare(
'SELECT nama FROM kota WHERE provinsi = :provinsi ORDER BY nama'
);
console.log('Jawa Barat :', JSON.stringify(cari.all({ provinsi: 'Jawa Barat' })));
console.log('Jawa Timur :', JSON.stringify(cari.all({ provinsi: 'Jawa Timur' })));
db.close();Output yang Dihasilkan:
Jawa Barat : [{"nama":"Bandung"},{"nama":"Bogor"}]
Jawa Timur : [{"nama":"Surabaya"}]Perhatikan dua hal pada contoh di atas. Nama properti object harus persis sama dengan nama placeholder tanpa awalan titik dua — :provinsi dipasangkan dengan properti provinsi. Urutan properti pada object juga tidak berpengaruh; yang dicocokkan adalah nama, bukan posisi.
Pola yang Sama di pg dan mysql2 #
Berbekal mekanisme tersebut, snippet pg dan mysql2 tinggal soal ejaan placeholder. Kedua contoh berikut adalah display-only karena membutuhkan server database:
const hasil = await pool.query(
'SELECT id, judul FROM tugas WHERE judul ILIKE $1 LIMIT $2',
[`%${kataKunci}%`, 10]
);const [rows] = await pool.execute(
'SELECT id, judul FROM tugas WHERE judul LIKE ? LIMIT ?',
[`%${kataKunci}%`, 10]
);Keduanya mengirim pola pencarian %kata% sebagai parameter — karakter persen yang tadinya berbahaya bila dirangkai kini hanyalah data. Perhatikan bahwa LIMIT pun diikat sebagai parameter: angka 10 dikirim sebagai angka, bukan teks yang perlu di-escape.
Anti-Pattern yang Harus Ditinggalkan #
Bentuk berikut adalah pola yang paling sering muncul di kode pemula — dan paling harus dihindari:
// Merangkai masukan ke SQL: celah SQL injection.
const sql = `SELECT * FROM pengguna WHERE nama = '${nama}'`;
const hasil = db.prepare(sql).all();Alasan pola ini terasa menggoda justru karena terlihat bersih dan berfungsi pada masukan normal. Persis seperti cariRentan pada eksperimen kita: query berhasil, hasil keluar, tidak ada error — sampai suatu hari masukan yang dirancang khusus datang. Ubah kebiasaan itu menjadi refleks: setiap nilai yang berasal dari luar program masuk lewat placeholder, titik.
Query yang aman kini menjadi refleks kita. Tapi ada satu dimensi performa yang belum tersentuh: setiap query yang kita kirim menumpang sebuah koneksi, dan koneksi itu sendiri punya harga. Berapa mahal harganya, dan bagaimana cara berbagi koneksi secara adil antar permintaan — itu urusan Chapter Connection Pooling.