SQL injection terjadi ketika teks dari pengguna menjadi bagian dari perintah SQL, bukan sekadar data. Serangan ini bertahan di daftar risiko keamanan web selama dua dekade karena pola salahnya sederhana: menyambung string input langsung ke teks query. Konsekuensinya berat — login yang bisa dilewati, data seluruh tabel terekspos, hingga tabel yang terhapus. Chapter ini membedah mekanisme serangan lewat simulasi in-memory tanpa database, lalu membangun pertahanan berlapis: parameter binding untuk data, allowlist untuk identifier, pesan error yang tersensor, dan least privilege untuk user database.

Payload Klasik dan Cara Kerjanya #

Aplikasi rentan biasanya membangun query login seperti ini: teks tetap disambung dengan input email tanpa pemeriksaan apa pun. Payload paling terkenal memanfaatkan tanda kutip:

PayloadEfek yang ditujuPrasyarat
' OR '1'='1Kondisi WHERE selalu benar → semua baris lolosTanda kutip tidak divalidasi
' OR '1'='1 --Sama, sekaligus menonaktifkan sisa query (-- = komentar)Driver mengizinkan komentar
x'; DROP TABLE pengguna;--Query kedua dieksekusi beruntun (stacked query)Driver mengizinkan multi-statement

Kunci payload pertama ada di tanda kutip pembuka: kutip tersebut menutup string milik aplikasi lebih awal sehingga sisa payload ditafsirkan sebagai kode SQL. Fragmen OR '1'='1 membuat kondisi selalu bernilai benar, dan baris pertama tabel — sering kali baris admin — lolos pemeriksaan login.

Kerentanan Penyambungan String #

Bentuk query hasil penyambungan bisa kita periksa persis tanpa menjalankan database. Function berikut menerima input apa pun dan menyambungnya mentah-mentah ke teks SQL:

go
func queryRentan(email string) string {
        return "SELECT id, nama FROM pengguna WHERE email = '" + email + "'"
}

Function di atas hanya mengembalikan string, tetapi pada aplikasi nyata string tersebut dikirim ke database — input ikut menentukan struktur perintah. Program lengkap di bawah memperlihatkan bentuk akhirnya untuk dua payload berbahaya, lalu membandingkannya dengan pola aman.

Parameter Binding: Nilai Dikirim sebagai Data #

Pertahanan utama adalah parameter binding: teks SQL memuat placeholder $1, nilai dikirim sebagai argumen terpisah, dan driver database yang meneruskannya — tanda kutip dan karakter khusus pada input tidak pernah ditafsirkan sebagai bagian dari perintah:

go
var nama string
err := db.QueryRowContext(ctx,
        "SELECT nama FROM pengguna WHERE email = $1", email).Scan(&nama)

Placeholder PostgreSQL berbentuk $1, MySQL memakai ?; detail ketiga method eksekusi dan placeholder per database dibahas tuntas pada chapter Query dan Parameter .

Allowlist untuk Kolom Sorting #

Fitur “urutkan berdasarkan kolom” adalah tempat favorit injection karena nama kolom tidak bisa di-binding. Solusinya adalah allowlist: daftar kecil nilai yang diizinkan; input di luar daftar ditolak keras. Pada demo di bawah, nama diterima dan disisipkan aman ke SQL, sedangkan payload berisi DROP TABLE ditolak sebelum menyentuh teks query.

Demo: Penyambungan vs Binding #

Program berikut membandingkan kedua pola secara berdampingan — semuanya di memori tanpa koneksi database, sehingga bentuk akhir query bisa diperiksa secara deterministik:

go
package main

import "fmt"

// queryRentan membangun SQL dengan menyambung string mentah dari user.
// POLA INI RENTAN — hanya untuk demonstrasi, jangan dipakai di produksi.
func queryRentan(email string) string {
	return "SELECT id, nama FROM pengguna WHERE email = '" + email + "'"
}

// queryAman mengembalikan teks SQL tetap dan argumen terpisah untuk placeholder.
func queryAman(email string) (string, []any) {
	return "SELECT id, nama FROM pengguna WHERE email = $1", []any{email}
}

// kolomDiizinkan adalah allowlist nama kolom untuk fitur sorting.
var kolomDiizinkan = map[string]bool{
	"nama":       true,
	"email":      true,
	"created_at": true,
}

// querySorting memvalidasi kolom lewat allowlist sebelum disisipkan ke SQL.
func querySorting(kolom string) (string, bool) {
	if !kolomDiizinkan[kolom] {
		return "", false
	}
	return "SELECT id, nama FROM pengguna ORDER BY " + kolom, true
}

func main() {
	// Payload klasik: kutip penutup + kondisi yang selalu benar.
	payload := "' OR '1'='1"

	fmt.Println("== Pola rentan: penyambungan string ==")
	fmt.Println("input :", payload)
	fmt.Println("query :", queryRentan(payload))
	fmt.Println("query :", queryRentan("x'; DROP TABLE pengguna;--"))

	fmt.Println()
	fmt.Println("== Pola aman: parameter binding ==")
	email := "[email protected]"
	q, args := queryAman(email)
	fmt.Println("query :", q)
	fmt.Println("args  :", args[0])

	fmt.Println()
	fmt.Println("== Kolom sorting: allowlist, bukan binding ==")
	for _, masukan := range []string{"nama", "password; DROP TABLE pengguna;--"} {
		q, ok := querySorting(masukan)
		if !ok {
			fmt.Printf("tolak %q: bukan kolom yang diizinkan\n", masukan)
			continue
		}
		fmt.Println("terima:", q)
	}
}

Output yang Dihasilkan:

text
== Pola rentan: penyambungan string ==
input : ' OR '1'='1
query : SELECT id, nama FROM pengguna WHERE email = '' OR '1'='1'
query : SELECT id, nama FROM pengguna WHERE email = 'x'; DROP TABLE pengguna;--'

== Pola aman: parameter binding ==
query : SELECT id, nama FROM pengguna WHERE email = $1
args  : [email protected]

== Kolom sorting: allowlist, bukan binding ==
terima: SELECT id, nama FROM pengguna ORDER BY nama
tolak "password; DROP TABLE pengguna;--": bukan kolom yang diizinkan

Mari kita telaah baris demi baris program di atas:

  1. Baris 6-10 (queryRentan): menyambung input langsung ke teks SQL — payload ' OR '1'='1 membuat kondisi WHERE selalu benar, terlihat pada baris kedua output.
  2. Baris 13-15 (queryAman): mengembalikan teks SQL tetap dengan placeholder $1 plus slice argumen; bentuk query tidak pernah berubah apa pun inputnya.
  3. Baris 18-22 (kolomDiizinkan): allowlist berupa map — hanya tiga nama kolom yang boleh menjadi bagian ORDER BY.
  4. Baris 25-32 (querySorting): memeriksa keanggotaan allowlist sebelum menyisipkan kolom; input asing ditolak dengan false.
  5. Baris 40-42: dua pemanggilan queryRentan memperlihatkan payload login bypass dan stacked query DROP TABLE utuh di dalam teks query.
  6. Baris 45-49: pola aman mencetak teks SQL tetap dan argumen terpisah — pada database/sql sungguhan, argumen inilah yang dikirim driver sebagai data murni.

Pesan Error yang Membocorkan Informasi #

Lapisan pertahanan berikutnya sering terlupakan: pesan error. Kesalahan driver database lazimnya memuat potongan teks SQL, nama tabel, dan nama kolom — informasi berharga bagi penyerang untuk memetakan struktur database. Kirim pesan generik ke klien; catat detailnya hanya di log internal:

go
if err != nil {
        slog.Error("query pengguna gagal", "error", err) // detail lengkap, internal
        http.Error(w, "terjadi kesalahan internal", http.StatusInternalServerError)
        return
}

Pola ini melanjutkan praktik penanganan error terstruktur pada section Error Handling : detail untuk log, pesan umum untuk publik.

Least Privilege untuk User Database #

Lapisan terakhir membatasi radius ledakan. User database aplikasi seharusnya hanya punya hak yang benar-benar diperlukan: SELECT, INSERT, UPDATE pada tabel aplikasi — tanpa DROP, tanpa GRANT, tanpa akses skema lain. Celah yang lolos pun tidak memberi penyerang kendali penuh atas database.

Referensi #

Pertahanan berlapis ini menutup celah pada lapisan query. Namun ada celah yang lebih sunyi: secret yang tertanam di kode. Chapter berikutnya membahas cara menyimpan secret dan konfigurasi dengan aman — jauh dari repo, log, dan panic message.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

sqli-demo.go

Unduh berkas
go
package main

import "fmt"

// queryRentan membangun SQL dengan menyambung string mentah dari user.
// POLA INI RENTAN — hanya untuk demonstrasi, jangan dipakai di produksi.
func queryRentan(email string) string {
	return "SELECT id, nama FROM pengguna WHERE email = '" + email + "'"
}

// queryAman mengembalikan teks SQL tetap dan argumen terpisah untuk placeholder.
func queryAman(email string) (string, []any) {
	return "SELECT id, nama FROM pengguna WHERE email = $1", []any{email}
}

// kolomDiizinkan adalah allowlist nama kolom untuk fitur sorting.
var kolomDiizinkan = map[string]bool{
	"nama":       true,
	"email":      true,
	"created_at": true,
}

// querySorting memvalidasi kolom lewat allowlist sebelum disisipkan ke SQL.
func querySorting(kolom string) (string, bool) {
	if !kolomDiizinkan[kolom] {
		return "", false
	}
	return "SELECT id, nama FROM pengguna ORDER BY " + kolom, true
}

func main() {
	// Payload klasik: kutip penutup + kondisi yang selalu benar.
	payload := "' OR '1'='1"

	fmt.Println("== Pola rentan: penyambungan string ==")
	fmt.Println("input :", payload)
	fmt.Println("query :", queryRentan(payload))
	fmt.Println("query :", queryRentan("x'; DROP TABLE pengguna;--"))

	fmt.Println()
	fmt.Println("== Pola aman: parameter binding ==")
	email := "[email protected]"
	q, args := queryAman(email)
	fmt.Println("query :", q)
	fmt.Println("args  :", args[0])

	fmt.Println()
	fmt.Println("== Kolom sorting: allowlist, bukan binding ==")
	for _, masukan := range []string{"nama", "password; DROP TABLE pengguna;--"} {
		q, ok := querySorting(masukan)
		if !ok {
			fmt.Printf("tolak %q: bukan kolom yang diizinkan\n", masukan)
			continue
		}
		fmt.Println("terima:", q)
	}
}