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:
| Payload | Efek yang dituju | Prasyarat |
|---|---|---|
' OR '1'='1 | Kondisi WHERE selalu benar → semua baris lolos | Tanda 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:
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:
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:
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:
== 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 diizinkanMari kita telaah baris demi baris program di atas:
- Baris 6-10 (
queryRentan): menyambung input langsung ke teks SQL — payload' OR '1'='1membuat kondisiWHEREselalu benar, terlihat pada baris kedua output. - Baris 13-15 (
queryAman): mengembalikan teks SQL tetap dengan placeholder$1plus slice argumen; bentuk query tidak pernah berubah apa pun inputnya. - Baris 18-22 (
kolomDiizinkan): allowlist berupa map — hanya tiga nama kolom yang boleh menjadi bagianORDER BY. - Baris 25-32 (
querySorting): memeriksa keanggotaan allowlist sebelum menyisipkan kolom; input asing ditolak denganfalse. - Baris 40-42: dua pemanggilan
queryRentanmemperlihatkan payload login bypass dan stacked queryDROP TABLEutuh di dalam teks query. - Baris 45-49: pola aman mencetak teks SQL tetap dan argumen terpisah — pada
database/sqlsungguhan, 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:
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 #
- OWASP: SQL Injection Prevention Cheat Sheet — panduan pencegahan resmi dari OWASP.
- Dokumentasi package database/sql
— method
QueryContextdanQueryRowContextdengan parameter binding. - Go Security — praktik keamanan di ekosistem Go.
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 berkaspackage 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)
}
}