SQL injection adalah celah klasik yang tidak pernah benar-benar pensiun: input pengguna dirangkai menjadi string SQL, dan penyerang menyelipkan kode SQL di dalam input tersebut. Chapter Parameterized Queries di section database telah memperkenalkan obatnya — placeholder ? yang mengikat nilai sebagai parameter. Kini kita bedah serangannya secara penuh, memahami varian yang lebih senyap, dan melengkapi parameterized query dengan lapisan pertahanan lain.

Anatomi Serangan: Login Dibobol #

Skenarionya sederhana: aplikasi login menerima username dan password, lalu menyusun query dengan f-string. Penyerang mengirim username yang membuat klausa WHERE selalu bernilai benar dan menonaktifkan sisa query dengan komentar --:

python
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE pengguna (id INTEGER PRIMARY KEY, username TEXT, password_hash TEXT)")
conn.execute("INSERT INTO pengguna VALUES (1, 'budi', 'hash-budi')")
conn.execute("INSERT INTO pengguna VALUES (2, 'siti', 'hash-siti')")
conn.commit()

username = "' OR '1'='1' -- "
password = "apa-saja"

query = (
    f"SELECT id, username FROM pengguna "
    f"WHERE username = '{username}' AND password_hash = '{password}'"
)
print("Query yang terbentuk:")
print(query)
print("Hasil query:", conn.execute(query).fetchall())

hasil = conn.execute(
    "SELECT id, username FROM pengguna WHERE username = ? AND password_hash = ?",
    (username, password),
).fetchall()
print("Dengan parameterized query:", hasil)

username_merusak = "budi'; DROP TABLE pengguna; --"
query_merusak = f"SELECT * FROM pengguna WHERE username = '{username_merusak}'"
try:
    conn.execute(query_merusak)
except sqlite3.ProgrammingError as e:
    print("Percobaan stacked query:", type(e).__name__, "-", e)
print("Tabel pengguna masih ada:", conn.execute("SELECT COUNT(*) FROM pengguna").fetchone()[0], "baris")

Output yang Dihasilkan:

text
Query yang terbentuk:
SELECT id, username FROM pengguna WHERE username = '' OR '1'='1' -- ' AND password_hash = 'apa-saja'
Hasil query: [(1, 'budi'), (2, 'siti')]
Dengan parameterized query: []
Percobaan stacked query: ProgrammingError - You can only execute one statement at a time.
Tabel pengguna masih ada: 2 baris

Mari kita bedah kejadiannya:

  1. Baris 9-10 (username = "' OR '1'='1' -- "): tanda kutip menutup string kosong, '1'='1' bernilai benar, dan -- menjadikan sisa baris sebagai komentar SQL — pemeriksaan password lenyap.
  2. Baris 12-15 (query = f"..."): f-string menempelkan input mentah ke SQL, menghasilkan query yang syaratnya selalu terpenuhi. Hasilnya: seluruh baris pengguna terbawa, dan login yang hanya mengecek “ada hasil” mengizinkan penyerang masuk.
  3. Baris 20-24 (conn.execute(..., (username, password))): versi parameterized mengirim nilai terpisah dari SQL. Input jahat kini hanya jadi teks pembanding, sehingga hasilnya kosong — serangan lumpuh total.
  4. Baris 26-32 (username_merusak): varian perusak menyusun dua statement sekaligus (stacked query). Driver sqlite3 menolaknya dengan ProgrammingError, tetapi jangan bersandar pada perilaku satu driver — parameterized query memutus kemungkinannya sejak awal.

Second-Order Injection #

Varian yang lebih senyap menyerang dua tahap: input jahat disimpan dengan aman lewat parameterized query, lalu di masa depan dirangkai ke string SQL oleh kode lain. Tabel transaksi, laporan bulanan, atau fitur ekspor sering menjadi tahap kedua yang lengah:

python
username_aneh = "budi' -- "
conn.execute("INSERT INTO pengguna VALUES (3, ?, 'hash-baru')", (username_aneh,))
query_laporan = f"SELECT id, username FROM pengguna WHERE username = '{username_aneh}'"
print("Query laporan:", query_laporan)
print("Hasil laporan :", conn.execute(query_laporan).fetchall())

Output yang Dihasilkan:

text
Query laporan: SELECT id, username FROM pengguna WHERE username = 'budi' -- '
Hasil laporan : [(1, 'budi')]

Penyimpanan pada baris pertama aman — nilai budi' -- masuk utuh sebagai data. Namun query laporan merangkai nilai tersebut dengan f-string, sehingga komentar -- memotong pemeriksaan dan mengembalikan baris yang tidak diminta. Pelajarannya: data yang sudah tersimpan bukan berarti bersih, karena konteks pemakaiannya berubah. Konsistensi memakai placeholder di seluruh query, termasuk query internal, adalah satu-satunya jalan.

Kebersihan Pesan Error #

Satu kebocoran yang sering luput adalah pesan error yang ditampilkan mentah kepada pengguna:

python
username_error = "budi'"
try:
    conn.execute(f"SELECT * FROM pengguna WHERE username = '{username_error}'")
except sqlite3.OperationalError as e:
    print("Pesan untuk pengguna : 'Terjadi galat internal, coba lagi'")
    print("Pesan yang bocor      :", e)

conn.close()

Output yang Dihasilkan:

text
Pesan untuk pengguna : 'Terjadi galat internal, coba lagi'
Pesan yang bocor      : unrecognized token: "'budi''"

Tanda kutip tunggal pada input membuat SQL rusak, dan pesan OperationalError yang dihasilkan SQLite mengutip fragmen query lengkap beserta input yang gagal. Bagi penyerang, pesan semacam ini adalah umpan balik gratis untuk menyempurnakan serangan berikutnya — struktur kolom, bentuk query, dan titik gagal semuanya terekspos. Praktik yang benar: pengguna menerima pesan generik, sedangkan detail teknis hanya menuju log internal yang aksesnya dibatasi.

Perlindungan Berlapis #

Parameterized query adalah pertahanan utama, tetapi prinsip defense in depth dari Chapter Secure Coding Principles mengajarkan kita menambah lapisan:

  • ORM — SQLAlchemy, Django ORM, dan Peewee mengirim nilai sebagai parameter secara internal saat kita memakai API-nya (misalnya session.query(User).filter_by(username=nama)). Celah hanya muncul ketika kita memaksa query mentah dengan text() atau f-string.
  • Least privilege — akun database aplikasi hanya diberi hak yang diperlukan:
Akun databaseHak aksesDipakai oleh
aplikasi_appSELECT, INSERT, UPDATE pada tabel bisnisServer aplikasi
aplikasi_migrationCREATE, ALTER, DROPSkrip migrasi saat rilis saja
laporan_readonlySELECT pada tabel laporanServer analitik

Dengan pemisahan ini, celah injection pada server aplikasi tidak langsung berujung pada tabel yang dihapus — akunnya memang tidak punya hak DROP.

Password yang tersimpan pun perlu berbentuk hash ber-salt dari Chapter Password Hashing, bukan teks polos seperti contoh "hash-budi" di atas. Lapisan-lapisan ini tidak saling menggantikan; masing-masing mempersempit kerusakan maksimum yang bisa dilakukan penyerang yang berhasil menembus lapis sebelumnya.