SQL Injection Adalah Bug Penyusunan String
SQL injection terjadi saat input pengguna akhirnya menjadi bagian dari teks SQL yang di-parse database-mu. Begitu batas itu kabur, begitu nilai yang diketik pengguna menjadi sintaks yang dieksekusi database, pengguna itu bisa melakukan apa pun yang bisa kamu lakukan.
Ini anti-pattern klasiknya, dalam pseudocode yang bisa muncul di bahasa apa pun:
-- DON'T DO THIS
query = "SELECT * FROM users WHERE name = '" + user_input + "'"
Jika user_input berisi Ada, kamu mendapat pencarian biasa. Jika user_input berisi ' OR 1=1 --, kamu mendapat:
SELECT * FROM users WHERE name = '' OR 1=1 --'
-- menjadikan tanda kutip di belakangnya sebagai komentar, OR 1=1 cocok dengan setiap baris, dan penyerang baru saja menguras isi tabel penggunamu. Versi yang lebih parah merangkai ; dan pernyataan kedua untuk menghapus tabel, mencuri data, atau memasukkan akun admin baru.
Celahnya tidak ada di SQLite. Celahnya ada di kode yang menyusun string itu.
Parameterized Query: Solusi yang Sebenarnya
Parameterized query memisahkan teks SQL dari nilai-nilainya. SQL-nya punya placeholder, yaitu ? atau :name, dan kamu memberikan nilainya secara terpisah. SQLite mem-parse dan mengompilasi SQL-nya sekali, lalu mengikat nilai-nilaimu ke rencana yang sudah dikompilasi. Nilai-nilai itu tidak bisa menjadi SQL.
Jalankan pencarian yang tampak rentan dengan cara yang aman:
Di shell SQLite kamu memang mengetik nilainya langsung, tapi di kode aplikasimu padanannya terlihat seperti ini (driver sqlite3 milik Python):
# Python - parameterized, safe
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
Berikan SQL dan tuple nilainya sebagai dua argumen terpisah. Driver mengirim keduanya ke SQLite secara terpisah. Bahkan jika user_input berisi ' OR 1=1 --, SQLite mencari pengguna yang namanya persis ' OR 1=1 -- dan tidak menemukan siapa pun.
Apa Arti "Aman" di Sini
Keamanannya bukan berasal dari pencocokan pola atau escape. Keamanannya bersifat struktural. SQLite mengompilasi pernyataan itu ke bentuk internal sebelum pernah melihat nilaimu:
-- The compiled statement has a slot, not a string.
SELECT * FROM users WHERE name = ?
^
placeholder slot
Saat kamu mengikat sebuah nilai, nilai itu masuk ke slot tersebut sebagai data bertipe: TEXT, INTEGER, BLOB, apa pun. SQLite tidak pernah mem-parse ulang nilai itu sebagai SQL. Tidak ada sintaks yang bisa disuntikkan penyerang karena parser sudah menyelesaikan tugasnya.
Itulah sebabnya parameterized query bisa diandalkan dengan cara yang tidak pernah bisa dicapai escape. Escape mencoba membersihkan karakter berbahaya dari sebuah string. Binding sama sekali tidak pernah membangun string berbahaya itu.
Jangan Tergoda Memakai Format String
Setiap bahasa punya jalan pintas yang menggoda, seperti f-string di Python, template literal di JavaScript, String.format di Java, dan semuanya adalah jebakan yang bisa melukai dirimu sendiri saat dipakai untuk SQL.
# DON'T - f-string interpolates the value into the SQL text
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")
# DON'T - same problem, % formatting
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)
# DO - placeholder + values argument
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
Dua cara pertama menyisipkan input pengguna ke string SQL sebelum driver sempat melihatnya. Saat SQLite menerima query-nya, kerusakannya sudah terjadi. Cara ketiga menjaga SQL dan nilainya tetap di jalur yang berbeda.
Aturan ini bersifat mekanis: jika kamu mendapati dirimu menyusun string SQL dengan +, f-string, format, atau template literal di tempat sebuah nilai seharusnya berada, berhenti dan pakai placeholder.
Banyak Parameter dan Placeholder Bernama
Query sungguhan biasanya punya lebih dari satu nilai. SQLite mendukung placeholder posisional ? maupun placeholder bernama :name:
Di kode aplikasi, query itu menjadi:
# Positional
cursor.execute(
"SELECT * FROM orders WHERE customer = ? AND status = ?",
("Ada", "paid"),
)
# Named - clearer when there are several parameters
cursor.execute(
"SELECT * FROM orders WHERE total > :min_total AND status = :status",
{"min_total": 50, "status": "paid"},
)
Parameter bernama lebih mudah dikembangkan. Begitu nilainya lebih dari tiga atau empat, ?, ?, ?, ? jadi permainan tebak-tebakan; :customer, :total, :status, :created_at sudah menjelaskan dirinya sendiri.
Identifier Butuh Pendekatan yang Berbeda
Parameter yang diikat hanya bisa dipakai untuk nilai: hal-hal di sisi kanan =, di dalam IN (...), di VALUES (...). Parameter itu tidak bisa dipakai untuk nama tabel, nama kolom, atau kata kunci SQL seperti ASC/DESC.
-- This does NOT work. The placeholder can't stand in for a column name.
SELECT * FROM users ORDER BY ? ASC
Jika kamu butuh identifier yang dinamis, misalnya membiarkan pengguna memilih kolom untuk pengurutan, validasi terhadap allowlist sebelum menyusun SQL-nya:
# Allowlist approach
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}
if sort_column not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"Invalid sort column: {sort_column}")
query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)
String dari pengguna dicek terhadap sekumpulan nilai aman yang sudah pasti sebelum string itu mendekati SQL. F-string hanya bisa diterima di sini karena sort_column tidak mungkin lagi berisi apa pun selain salah satu dari tiga nama yang sudah ditulis langsung di kode.
Contoh Nyata Upaya Injection yang Digagalkan
Mari lihat kedua versi berdampingan dengan input yang berniat jahat. Siapkan tabel pengguna kecil:
Bentuk yang rentan mengembalikan setiap pengguna. Bentuk berparameter mencari pengguna yang namanya persis ' OR 1=1 -- dan tidak mengembalikan apa pun. Input yang sama, hasil yang sama sekali berbeda, karena di kasus kedua nilai itu tidak pernah menjadi SQL.
Daftar Periksa Singkat
- Pakai placeholder
?atau:nameuntuk setiap nilai yang berasal dari luar kodemu: input pengguna, isi request, environment variable, apa pun yang tidak kamu tulis langsung di kode. - Jangan pernah menyusun SQL dengan
+, f-string, atauformatdi tempat sebuah nilai berada. - Untuk nama tabel atau kolom yang dinamis, validasi terhadap allowlist yang tetap sebelum menyisipkannya ke query.
- Percayakan pada driver. Jangan menulis fungsi escape tanda kutip sendiri. Mekanisme parameter yang diikat lebih tua, lebih teruji, dan benar.
- Review query timmu dengan satu pertanyaan: apakah ada input pengguna yang digabungkan ke teks SQL? Jika ya, perbaiki.
Jadikan ini kebiasaan yang otomatis, dan SQL injection tidak lagi menjadi jenis bug yang perlu kamu pikirkan.
Selanjutnya: Terhubung dari Aplikasi
Kamu sudah melihat bentuk query yang aman: placeholder di dalam SQL, nilai yang diberikan terpisah. Halaman berikutnya membahas cara benar-benar menghubungkan SQLite dari kode aplikasi sungguhan di Python, Node.js, dan beberapa bahasa lain, termasuk pengelolaan koneksi dan posisi parameterized query dalam alur request yang biasa.
Pertanyaan yang sering diajukan
Apakah SQLite rentan terhadap SQL injection?
Ya. SQLite sama rentannya dengan database SQL lain jika kode aplikasi membangun query dengan menggabungkan string. Solusinya bukan pengaturan SQLite, melainkan cara kamu meneruskan nilai dari aplikasimu. Pakai parameterized query dengan placeholder ? atau :name, dan driver akan menanganinya dengan aman.
Bagaimana parameterized query mencegah SQL injection?
Saat kamu memakai placeholder seperti ?, SQLite mem-parse dan mengompilasi query-nya lebih dulu, lalu mengikat nilai-nilaimu ke slot di statement yang sudah dikompilasi. Nilai-nilai itu tidak akan pernah menjadi sintaks SQL; nilai itu diperlakukan sebagai data, titik. Tidak ada string yang bisa dibobol oleh penyerang.
Bisakah saya cukup meng-escape tanda kutip di input pengguna?
Tidak. Escape manual itu rapuh: kamu akan melewatkan kasus pinggiran (tanda kutip Unicode, trik encoding, penanda komentar) dan merilis celah keamanan. Driver menyediakan parameter ? dan :name justru supaya kamu tidak perlu memikirkan escape. Pakai setiap saat, bahkan untuk nilai yang kamu 'tahu' aman.
Bagaimana dengan nama tabel atau kolom yang berasal dari input pengguna?
Parameter yang diikat hanya bisa dipakai untuk nilai, bukan identifier. Jika nama tabel atau kolom harus dinamis, validasi dulu terhadap allowlist berisi nama-nama yang sudah diketahui sebelum menyisipkannya ke SQL. Jangan pernah meneruskan identifier mentah dari pengguna lewat format string.