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:
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:
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:
- Baris 19 (perangkai string): template literal menyisipkan
namadansandiapa adanya. Pada masukan normal, query hasilnya memang wajar — dan justru itulah yang membuat pola ini terasa aman selama bertahun-tahun. - Baris 30 (
"Budi' --"): serangannya hanya dua belas karakter. Tanda kutip setelahBudimenutup string literal'Budi', lalu--memulai komentar SQL yang menelan sisa baris — termasuk pemeriksaanAND sandi = ...yang seharusnya menjadi gerbang kedua. - Baris 32-33 (hasil
loginRentan): satu baris kembalian berarti login berhasil. Penyerang masuk sebagai Budi tanpa mengetahui sandi Budi sama sekali. - Baris 35-36 (hasil
loginAman): string serangan yang identik dikirim sebagai parameter, jadi database membandingkannya sebagai teks biasa dengan isi kolomnama. 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. - 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:
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:
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_ERRORDua method memberi dua nasib bagi string yang sama:
- Baris 25-27 (
prepare()): method ini mengompilasi tepat satu statement — koneksi ke perilakusqlite3_prepare_v2di bawahnya — sehinggaSELECTdi depan dijalankan (hasilnya kosong karena namahancurtidak ada) dan sisa string setelah titik koma tidak pernah dieksekusi. Tabel selamat. - Baris 29-36 (
exec()): method ini memang dirancang menjalankan satu atau banyak statement dari string mentah — berguna untuk skrip DDL — sehinggaDROP TABLEbenar-benar tereksekusi. Query pengecekan berikutnya gagal dengan kodeERR_SQLITE_ERRORkarena 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:
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:
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: sandiPerhatikan 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:
| Lapisan | Peran | Tempat pembahasan |
|---|---|---|
| Validasi masukan | Tolak nilai yang tidak masuk akal sebelum menyentuh database | Chapter Validasi dan Sanitasi Input |
| Parameter binding | Nilai tidak pernah diurai sebagai kode SQL | Section Database Integration |
| Allow-list identifier | Kolom/tabel urutan hanya dari daftar yang dikenal | Demo terakhir di atas |
| Least privilege pada akun DB | Akun aplikasi tanpa hak DROP membuat serangan bertumpuk gagal sejak awal | Section 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).