← Semua pembelajaran / Astro Nol → Portal Berita
Fase 7 · Keamanan

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.

Sumber asli owasp.org Artikel Rangkuman ~7 menit baca

Intisari

  • Semua nilai di where(), values(), dan set() jadi parameter terikat. Aman.
  • Nama tabel dan kolom diperiksa terhadap tipe skema. Nama yang tidak ada tidak bisa dikompilasi.
  • Jalur yang tersisa: sql mentah, sql.raw(), dan nama kolom dinamis untuk ORDER BY.
  • sql dengan ${} 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 CI3RisikoDi Kysely
$this->db->query("… WHERE id = $id")InjeksiTidak ada padanannya tanpa sql.raw
->where("status", $s)Aman (di-escape)Aman (parameter)
->order_by($_GET['urut'])InjeksiMasih 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.