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:

js
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:

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

  1. Baris 18-22 (cariRentan): template literal menyisipkan nama apa adanya ke dalam string SQL. Masukan normal seperti Siti memang menghasilkan query yang wajar.
  2. Baris 29 (const serangan): string ini sengaja dibuat sebagai masukan jahat. Saat dirangkai, tanda kutip tunggal di dalamnya menutup string '...' di SQL lebih awal, lalu OR '1'='1' menjadi kode SQL sungguhan yang selalu bernilai benar.
  3. Baris 31 (hasil cariRentan): query berubah makna menjadi “ambil semua pengguna” — kedua baris lengkap dengan kolom sandi ikut terekspos. Di aplikasi nyata, varian serangan yang sama bisa menambahkan '; DROP TABLE ... atau panggilan administratif lain.
  4. Baris 32 (hasil cariAman): string serangan yang identik dikirim sebagai parameter, maka database memperlakukannya sebagai teks biasa yang dibandingkan dengan kolom nama. Tidak ada pengguna yang cocok, hasilnya array kosong.
  5. 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:

DriverPlaceholder posisionalPlaceholder bernamaBentuk argumen
node:sqlite?:namaNilai 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:

js
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:

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

js Query berparameter dengan pg
const hasil = await pool.query(
  'SELECT id, judul FROM tugas WHERE judul ILIKE $1 LIMIT $2',
  [`%${kataKunci}%`, 10]
);
js Query berparameter dengan mysql2
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:

js Pola berbahaya — jangan ditiru
// 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.