Di antara semua celah keamanan aplikasi web, SQL injection adalah yang paling lama dan paling sering memuat berita kebocoran data. Akar masalahnya selalu sama: nilai yang seharusnya menjadi data malah dirangkai menjadi bagian dari kode SQL. Section Database Integration telah memperkenalkan parameter binding sebagai solusi; chapter ini membedah sisanya dari sudut penyerang — bagaimana string sederhana bisa mengubah makna query, membuka gerbang login tanpa sandi, bahkan menghapus tabel. Seluruh demo berjalan pada database :memory: lokal dengan data fiktif Budi dan Siti, murni untuk pembelajaran; versi amannya selalu menyertai setiap contoh rentan.

Ketika Data Menjadi Kode #

SQL adalah bahasa: string seperti 'Budi' adalah data, sedangkan WHERE, OR, dan -- adalah instruksi. Database tidak bisa membedakan keduanya pada string mentah — database hanya melihat satu deretan karakter lalu mengurainya. Selama aplikasi menyusun query dengan merangkai masukan ke string SQL, masukan tersebut ikut diurai sebagai kode. Kunci serangannya ada pada tanda kutip: masukan yang mengandung ' menutup string literal lebih awal, sehingga karakter setelahnya dibaca sebagai SQL baru oleh mesin database. Marilah kita lihat betapa sedikitnya yang dibutuhkan.

Login Bypass dengan Komentar #

Demo pertama menyiapkan tabel pengguna lalu membandingkan dua function login. Function loginRentan merangkai nama dan sandi langsung ke string SQL; function loginAman mengirim keduanya sebagai parameter terikat:

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: nama dan sandi dirangkai langsung ke string SQL.
function loginRentan(nama, sandi) {
  const sql = `SELECT id, nama FROM pengguna WHERE nama = '${nama}' AND sandi = '${sandi}'`;
  console.log('SQL yang terbentuk:', sql);
  return db.prepare(sql).all();
}

// Versi AMAN: masukan dikirim sebagai parameter terikat.
function loginAman(nama, sandi) {
  return db.prepare('SELECT id, nama FROM pengguna WHERE nama = ? AND sandi = ?').all(nama, sandi);
}

// Serangan: tutup string nama lebih awal, lalu " -- " mengomentari sisanya.
const serangan = { nama: "Budi' --", sandi: 'salah-total' };

const hasilRentan = loginRentan(serangan.nama, serangan.sandi);
console.log('loginRentan    :', hasilRentan.length, 'baris -', JSON.stringify(hasilRentan));

const hasilAman = loginAman(serangan.nama, serangan.sandi);
console.log('loginAman      :', hasilAman.length, 'baris -', JSON.stringify(hasilAman));

const hasilSah = loginAman('Budi', 'rahasia-budi');
console.log('loginAman sah  :', hasilSah.length, 'baris -', JSON.stringify(hasilSah));

db.close();

Output yang Dihasilkan:

text
SQL yang terbentuk: SELECT id, nama FROM pengguna WHERE nama = 'Budi' --' AND sandi = 'salah-total'
loginRentan    : 1 baris - [{"id":1,"nama":"Budi"}]
loginAman      : 0 baris - []
loginAman sah  : 1 baris - [{"id":1,"nama":"Budi"}]

Baca baris SQL yang terbentuk pada output di atas dengan teliti, lalu cocokkan dengan uraian berikut:

  1. Baris 19 (perangkai string): template literal menyisipkan nama dan sandi apa adanya. Pada masukan normal, query hasilnya memang wajar — dan justru itulah yang membuat pola ini terasa aman selama bertahun-tahun.
  2. Baris 30 ("Budi' --"): serangannya hanya dua belas karakter. Tanda kutip setelah Budi menutup string literal 'Budi', lalu -- memulai komentar SQL yang menelan sisa baris — termasuk pemeriksaan AND sandi = ... yang seharusnya menjadi gerbang kedua.
  3. Baris 32-33 (hasil loginRentan): satu baris kembalian berarti login berhasil. Penyerang masuk sebagai Budi tanpa mengetahui sandi Budi sama sekali.
  4. Baris 35-36 (hasil loginAman): string serangan yang identik dikirim sebagai parameter, jadi database membandingkannya sebagai teks biasa dengan isi kolom nama. Tidak ada kecocokan, hasil array kosong — serangan berubah menjadi percobaan login yang gagal, semestinya memicu pembatasan percobaan seperti yang kita bahas di Chapter Rate Limiting.
  5. Baris 38-39 (login sah): pengguna sungguhan dengan sandi benar tetap bisa masuk. Pertahanan yang benar tidak memutus fungsionalitas.

Serangan Bertumpuk dan Perilaku exec #

Varian kedua menyerang dengan stacked query: menambahkan statement kedua setelah titik koma. Tabel dihapus lewat DROP TABLE. Hasilnya menarik karena bergantung pada method yang dipakai menjalankan string tersebut:

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
  )
`);
db.exec(
  `INSERT INTO pengguna (nama, sandi) VALUES ('Budi', 'rahasia-budi'), ('Siti', 'rahasia-siti')`
);

// Serangan bertumpuk: statement pertama gagal dengan sengaja,
// statement kedua menjalankan DROP TABLE.
const namaSerangan = "hancur'; DROP TABLE pengguna; --";
const sqlRentan =
  `SELECT id, nama FROM pengguna WHERE nama = '${namaSerangan}' AND sandi = 'apa-saja'`;

console.log('SQL yang terbentuk:', sqlRentan);
console.log('jumlah baris awal :', db.prepare('SELECT COUNT(*) AS jumlah FROM pengguna').get().jumlah);

console.log('--- string yang sama lewat prepare() ---');
console.log('hasil :', JSON.stringify(db.prepare(sqlRentan).all()));
console.log('tabel : masih ada,', db.prepare('SELECT COUNT(*) AS jumlah FROM pengguna').get().jumlah, 'baris');

console.log('--- string yang sama lewat exec() ---');
db.exec(sqlRentan);
try {
  db.prepare('SELECT COUNT(*) AS jumlah FROM pengguna').get();
  console.log('tabel : masih ada');
} catch (error) {
  console.log('tabel : hilang -', error.code);
}

db.close();

Output yang Dihasilkan:

text
SQL yang terbentuk: SELECT id, nama FROM pengguna WHERE nama = 'hancur'; DROP TABLE pengguna; --' AND sandi = 'apa-saja'
jumlah baris awal : 2
--- string yang sama lewat prepare() ---
hasil : []
tabel : masih ada, 2 baris
--- string yang sama lewat exec() ---
tabel : hilang - ERR_SQLITE_ERROR

Dua method memberi dua nasib bagi string yang sama:

  1. Baris 25-27 (prepare()): method ini mengompilasi tepat satu statement — koneksi ke perilaku sqlite3_prepare_v2 di bawahnya — sehingga SELECT di depan dijalankan (hasilnya kosong karena nama hancur tidak ada) dan sisa string setelah titik koma tidak pernah dieksekusi. Tabel selamat.
  2. Baris 29-36 (exec()): method ini memang dirancang menjalankan satu atau banyak statement dari string mentah — berguna untuk skrip DDL — sehingga DROP TABLE benar-benar tereksekusi. Query pengecekan berikutnya gagal dengan kode ERR_SQLITE_ERROR karena tabelnya sudah tidak ada.

Identifier Tidak Bisa Diikat #

Parameter binding melindungi nilai, tetapi sebagian bagian query adalah identifier — nama kolom, nama tabel, arah urutan — yang tidak bisa diikat sebagai parameter ? karena SQLite memperlakukan parameter sebagai nilai. Solusinya kembali ke allow-list dari Chapter Validasi dan Sanitasi Input:

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-zz');
sisip.run('Siti', 'rahasia-aa');
sisip.run('Dewi', 'rahasia-mm');

// Nama kolom tidak bisa diikat sebagai parameter "?" karena SQLite
// memperlakukan parameter sebagai NILAI, bukan identifier - maka nama
// kolom diperiksa terhadap allow-list sebelum dirangkai.
const KOLOM_VALID = ['id', 'nama'];

function daftarRentan(urut) {
  return db.prepare(`SELECT id, nama FROM pengguna ORDER BY ${urut}`).all();
}

function daftarAman(urut) {
  if (KOLOM_VALID.includes(urut) === false) {
    throw new Error(`kolom urut tidak dikenal: ${urut}`);
  }
  return db.prepare(`SELECT id, nama FROM pengguna ORDER BY ${urut}`).all();
}

console.log('rentan, urut "sandi" (urutan baris membocorkan urutan sandi):');
console.log(' ', JSON.stringify(daftarRentan('sandi')));

console.log('aman, urut "nama":');
console.log(' ', JSON.stringify(daftarAman('nama')));

console.log('aman, urut "sandi":');
try {
  daftarAman('sandi');
} catch (error) {
  console.log(' DITOLAK -', error.message);
}

db.close();

Output yang Dihasilkan:

text
rentan, urut "sandi" (urutan baris membocorkan urutan sandi):
  [{"id":2,"nama":"Siti"},{"id":3,"nama":"Dewi"},{"id":1,"nama":"Budi"}]
aman, urut "nama":
  [{"id":1,"nama":"Budi"},{"id":3,"nama":"Dewi"},{"id":2,"nama":"Siti"}]
aman, urut "sandi":
 DITOLAK - kolom urut tidak dikenal: sandi

Perhatikan hasilnya: daftarRentan('sandi') tidak menampilkan isi kolom sandi, tetapi urutan barisnya mengikuti nilai sandi — penyerang yang tahu urutan alfabetis sandi bisa menyimpulkan posisi relatifnya lewat query berulang. Kebocoran tidak selalu berbentuk data yang tampil. Pada versi aman, daftarAman('sandi') ditolak di depan oleh allow-list KOLOM_VALID sebelum string SQL sempat dirangkai; nilai di luar daftar tidak akan pernah menyentuh perangkai string, apa pun bentuknya.

Pertahanan Berlapis untuk Aplikasi Nyata #

Empat lapisan berikut bekerja bersama, dan masing-masing sudah kita bahas di tempatnya:

LapisanPeranTempat pembahasan
Validasi masukanTolak nilai yang tidak masuk akal sebelum menyentuh databaseChapter Validasi dan Sanitasi Input
Parameter bindingNilai tidak pernah diurai sebagai kode SQLSection Database Integration
Allow-list identifierKolom/tabel urutan hanya dari daftar yang dikenalDemo terakhir di atas
Least privilege pada akun DBAkun aplikasi tanpa hak DROP membuat serangan bertumpuk gagal sejak awalSection Security, chapter pertama

Mekanisme parameter binding pada node:sqlite terdokumentasi di db.prepare() dan perilaku multi-statement pada db.exec() ; rujukan sintaks lengkap termasuk komentar -- tersedia di sqlite.org/lang.html . SQL injection menyusup lewat pintu data yang dirangkai menjadi kode. Celah berikutnya menyusup lewat pintu yang berbeda — data yang kembali ke pengguna lalu dijalankan sebagai kode di browser. Itulah XSS, topik Chapter Cross-Site Scripting (XSS).