Injeksi SQL — apa yang Kysely tutup, dan apa yang tidak
Kabar baiknya: pindah dari CI3 ke Kysely menghapus mayoritas risiko injeksi tanpa kamu melakukan apa pun. Kabar buruknya: sisa tiganya justru di tempat yang tidak dicurigai.
Intisari
- Semua nilai di
where(),values(), danset()jadi parameter terikat. Aman. - Nama tabel dan kolom diperiksa terhadap tipe skema. Nama yang tidak ada tidak bisa dikompilasi.
- Jalur yang tersisa:
sqlmentah,sql.raw(), dan nama kolom dinamis untukORDER BY. sqldengan${}tetap aman — ia memparameterkan.sql.raw()tidak.- Pengurutan dari parameter URL adalah jalur injeksi paling umum di portal data. Pakai daftar-izin.
Apa yang sudah tertutup
const q = Astro.url.searchParams.get("q") ?? "";
const hasil = await dbBaca
.selectFrom("artikel")
.selectAll()
.where("judul", "like", `%${q}%`)
.execute();
-- SQL yang dihasilkan:
select * from `artikel` where `judul` like ?
-- parameter: ["%' OR 1=1 --%"] ← literal, bukan sintaks
Template literal di baris where itu terlihat mencurigakan bagi orang yang terbiasa dengan
PHP, tapi ia hanya menyusun nilai string yang kemudian dikirim sebagai parameter terikat. Tidak
ada cara isinya ditafsirkan sebagai SQL.
| Di CI3 | Risiko | Di Kysely |
|---|---|---|
$this->db->query("… WHERE id = $id") | Injeksi | Tidak ada padanannya tanpa sql.raw |
->where("status", $s) | Aman (di-escape) | Aman (parameter) |
->order_by($_GET['urut']) | Injeksi | Masih berisiko — lihat di bawah |
->where("id IN ($ids)") | Injeksi | .where("id", "in", ids) — aman |
Jalur 1: sql mentah
import { sql } from "kysely";
// AMAN: ${} jadi parameter terikat
const hasil = await sql<{ id: number }>`
SELECT id FROM artikel
WHERE MATCH(judul, isi) AGAINST (${kata} IN NATURAL LANGUAGE MODE)
LIMIT 20
`.execute(dbBaca);
// BERBAHAYA: sql.raw menyisipkan apa adanya
const kolom = Astro.url.searchParams.get("urut");
await sql`SELECT * FROM artikel ORDER BY ${sql.raw(kolom!)}`.execute(dbBaca);
// ?urut=id; DROP TABLE artikel; --
Aturan: sql.raw() hanya boleh menerima nilai yang kamu tulis di kode, bukan
yang datang dari luar. Kalau ada variabel di dalamnya, ia harus berasal dari daftar konstanta.
Jadikan ini aturan lint kalau timmu lebih dari satu orang — sql.raw adalah satu-satunya
fungsi di seluruh basis kodemu yang bisa menghasilkan injeksi.
Jalur 2: pengurutan dinamis
Ini yang paling sering muncul di portal data, karena tabel yang bisa diurutkan adalah fitur yang wajar.
// BURUK
q = q.orderBy(Astro.url.searchParams.get("urut") as any, "desc");
// BAIK: daftar-izin lewat Zod
const SkemaUrut = z.enum(["terbit_pada", "judul", "hits", "kapitalisasi"]);
const SkemaArah = z.enum(["asc", "desc"]);
const urut = SkemaUrut.catch("terbit_pada").parse(Astro.url.searchParams.get("urut"));
const arah = SkemaArah.catch("desc").parse(Astro.url.searchParams.get("arah"));
q = q.orderBy(urut, arah);
.catch(…) membuat nilai yang tidak sah jatuh ke default alih-alih melempar — untuk parameter
URL yang bisa datang dari tautan lama atau crawler, itu perilaku yang lebih baik daripada 500.
Jalur 3: nama kolom dinamis
// Filter fasetted di halaman data perusahaan
const FILTER_SAH = {
sektor: "p.sektor_id",
bursa: "p.bursa_id",
negara: "p.negara_kode",
} as const;
for (const [kunci, nilai] of Astro.url.searchParams) {
const kolom = FILTER_SAH[kunci as keyof typeof FILTER_SAH];
if (!kolom) continue; // abaikan yang tidak dikenal
q = q.where(kolom as any, "=", nilai); // nilai tetap jadi parameter
}
Pemetaan dari nama parameter publik ke nama kolom internal punya dua keuntungan sekaligus: injeksi jadi mustahil, dan nama kolom databasemu tidak bocor ke URL publik — sehingga mengubah skema tidak merusak tautan yang sudah tersebar.
Lapis terakhir: hak akses database
-- Pengguna aplikasi TIDAK butuh DROP, ALTER, atau CREATE
CREATE USER 'portal_app'@'%' IDENTIFIED BY '…';
GRANT SELECT, INSERT, UPDATE, DELETE ON portal.* TO 'portal_app'@'%';
-- Pengguna migrasi terpisah, dipakai hanya oleh pipeline
CREATE USER 'portal_migrasi'@'%' IDENTIFIED BY '…';
GRANT ALL PRIVILEGES ON portal.* TO 'portal_migrasi'@'%';
-- Pembacaan analitik: hanya baca, dan hanya dari replica
CREATE USER 'portal_bi'@'%' IDENTIFIED BY '…';
GRANT SELECT ON portal.* TO 'portal_bi'@'%';
Ini pertahanan berlapis yang sering dilewati dan biayanya sepuluh menit. Kalau suatu hari ada
injeksi yang lolos — lewat sql.raw yang terlewat review, atau lewat dependensi yang
dikompromikan — kredensial yang tidak punya DROP membatasi kerusakannya dari "database
hilang" jadi "data terbaca". Bedanya besar.
Uji
for m in "' OR '1'='1" "'; DROP TABLE artikel; --" "1 UNION SELECT 1,2,3" "%' AND SLEEP(5) --"; do
printf '%s → ' "$m"
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \
--get --data-urlencode "q=$m" http://localhost:4321/pencarian
done
Yang kamu cari: semuanya mengembalikan 200 dengan hasil kosong, dan yang terakhir tidak memakan lima detik. Kalau ada yang memakan lima detik, kamu punya injeksi berbasis waktu — jenis yang tidak menampilkan apa pun di layar dan karena itu paling lama tidak ketahuan.
Latihan: cari seluruh pemakaian sql.raw dan as any di basis kodemu
dengan grep -rn "sql.raw\|as any" src/. Untuk tiap satu, buktikan nilainya tidak berasal
dari luar. Lalu buat halaman daftar dengan pengurutan dari parameter URL, dan uji dengan payload di
atas.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.