SQL Aman dari Kode

Mencegah SQL injection dengan parameter binding dan mengelola siklus hidup koneksi.

Daftar isi

Query yang dirakit dengan menempelkan masukan pengguna ke teks SQL membuka celah SQL injection, yaitu penyusupan pernyataan berbahaya melalui nilai yang belum disaring. Topik ini memperagakan celah tersebut dengan contoh kecil yang aman diulang, menutupnya lewat parameter binding, menetapkan aturan binding untuk identifier seperti nama tabel, dan menutup dengan kaidah siklus hidup koneksi agar program tetap rapi di bagian yang bukan SQL-nya.

Cara injection menyusup#

Celah muncul karena penggabungan teks terjadi sebelum SQLite mem-parse query. Ketika masukan pengguna ditempel ke dalam teks SQL lewat f-string, kutip tunggal pada masukan menutup string yang ada dan sisanya dianggap pernyataan SQL baru. Contoh berikut menyimulasikan input bernilai jahat ' OR '1'='1, yang lazim dikirim lewat formulir pencarian pada program nyata.

python
import sqlite3

koneksi = sqlite3.connect(":memory:")
koneksi.execute(
    "CREATE TABLE anggota ("
    " id    INTEGER PRIMARY KEY,"
    " nama  TEXT NOT NULL,"
    " denda REAL NOT NULL DEFAULT 0)"
)
anggota = [("Ali", 0), ("Budi", 2500), ("Citra", 5000)]
koneksi.executemany("INSERT INTO anggota (nama, denda) VALUES (?, ?)", anggota)
koneksi.commit()

input_nama = "' OR '1'='1"
query = f"SELECT nama, denda FROM anggota WHERE nama = '{input_nama}'"
print("kueri :", query)
for baris in koneksi.execute(query):
    print("bocor :", baris)
koneksi.close()
text Keluaran
kueri : SELECT nama, denda FROM anggota WHERE nama = '' OR '1'='1'
bocor : ('Ali', 0.0)
bocor : ('Budi', 2500.0)
bocor : ('Citra', 5000.0)

Query yang tercetak sudah bukan lagi query yang dimaksud. Kutip pembuka pada input menutup string pembatas nama, lalu OR '1'='1 menambahkan kondisi yang selalu benar, sehingga filter nama nyaris menjadi tanpa arti dan ketiga baris terbaca. Pola serupa dapat muncul tanpa kutip sama sekali pada nilai angka, misalnya pada filter id, dan juga pada klausa ORDER BY yang menyusun urutan hasil. Karena ragamnya banyak, penyaringan karakter tertentu pada input tidak pernah cukup, dan justru itulah sebabnya perbaikannya berada pada cara query dirakit, bukan pada isinya. Pada aplikasi sungguhan, pola yang sama dipakai untuk membaca tabel mana pun, menulis data yang tidak sah, atau menghapus tabel, dan kerusakannya tidak terlihat dari kode sumber karena teks query yang ditulis programmer memang tampak benar.

Parameter binding menutup celah#

Parameter binding mengirim nilai secara terpisah dari teks query. Placeholder menandai posisi nilai pada teks SQL, sedangkan nilainya dikirim sebagai argumen terpisah yang diperlakukan murni sebagai data. Kutip tunggal di dalam nilai tidak pernah mengubah struktur query karena SQLite tidak mem-parse isi nilai sebagai SQL, melainkan membandingkannya seperti string biasa.

python
import sqlite3

koneksi = sqlite3.connect(":memory:")
koneksi.execute(
    "CREATE TABLE anggota ("
    " id    INTEGER PRIMARY KEY,"
    " nama  TEXT NOT NULL,"
    " denda REAL NOT NULL DEFAULT 0)"
)
anggota = [("Ali", 0), ("Budi", 2500), ("Citra", 5000)]
koneksi.executemany("INSERT INTO anggota (nama, denda) VALUES (?, ?)", anggota)
koneksi.commit()

input_nama = "' OR '1'='1"
kur = koneksi.execute(
    "SELECT nama, denda FROM anggota WHERE nama = ?", (input_nama,)
)
print("baris hasil:", len(kur.fetchall()))
koneksi.close()
text Keluaran
baris hasil: 0

Input yang sama kini menghasilkan nol baris karena SQLite mencari anggota yang namanya benar-benar sama dengan teks ' OR '1'='1, dan tidak ada anggota dengan nama semacam itu. Query yang dijalankan lewat metode execute juga menjadi prepared statement, yaitu pernyataan yang dikompilasi lebih dahulu sebelum nilai diisi, sehingga query yang sama dapat dijalankan berulang kali dengan nilai berbeda tanpa diparsing ulang. Aturan praktisnya tegas, yaitu setiap nilai yang berasal dari luar kode program dikirim lewat binding, tanpa kecuali.

Identifier tidak boleh diinterpolasi#

Placeholder hanya boleh menempati posisi nilai, yaitu di dalam ekspresi WHERE atau VALUES. Nama tabel, nama kolom, dan kata kunci lain yang disebut identifier tidak dapat menjadi placeholder, karena SQLite menolak pernyataan yang menyisipkan placeholder pada posisi struktur SQL. Apabila identifier benar-benar harus dinamis, misalnya kolom pengurutan dipilih pengguna, teksnya harus disaring dengan daftar izin (whitelist), yaitu himpunan nilai yang dinyatakan sah oleh program sebelum dipakai.

python
import sqlite3

koneksi = sqlite3.connect(":memory:")
koneksi.execute("CREATE TABLE buku (judul TEXT, penulis TEXT)")
koneksi.execute("INSERT INTO buku VALUES ('Anak Sungai', 'Rahmat Hidayat')")

kolom_diizinkan = {"judul", "penulis"}
input_kolom = "penulis; DROP TABLE buku"

if input_kolom not in kolom_diizinkan:
    print(f"input '{input_kolom}' ditolak")
else:
    kur = koneksi.execute(f"SELECT {input_kolom} FROM buku")
    print(kur.fetchall())

print("tabel buku masih ada:", koneksi.execute(
    "SELECT count(*) FROM sqlite_master WHERE name = 'buku'"
).fetchone()[0])
koneksi.close()
text Keluaran
input 'penulis; DROP TABLE buku' ditolak
tabel buku masih ada: 1

Masukan yang menyusupkan pernyataan DROP TABLE ditolak karena tidak ada pada daftar izin, dan tabel buku tetap utuh. Cara yang lebih tegas daripada menguji keanggotaan daftar izin adalah memetakan masukan ke string yang ditetapkan program, sehingga teks yang menempel ke query berasal dari kode, bukan dari pengguna. Pembanding karakter pada input sebaiknya dihindari karena daftar karakter yang harus disaring mudah terlewat satu kasus. Bila masukan ternyata sah, nilai itulah yang boleh ditempel ke teks query, dan nilai data tetap dikirim lewat binding sebagaimana biasa.

Siklus hidup koneksi#

Koneksi dibuka sesaat sebelum diperlukan dan ditutup segera setelah pekerjaan selesai, bukan dibuka di awal program lalu dibiarkan sepanjang proses. Pernyataan with koneksi: mengurus commit atau rollback pada transaksi seperti yang ditunjukkan topik integrasi Python, sedangkan penutupan koneksi dinyatakan dengan close() atau dibungkus contextlib.closing agar otomatis. Pola with contextlib.closing(sqlite3.connect(...)) as koneksi: pada skrip katalog.py menggabungkan keduanya dalam satu baris.

Satu proses cukup memegang satu koneksi untuk satu basis data. SQLite mengunci berkas basis data saat menulis, sehingga membuka banyak koneksi dalam satu proses justru menambah peluang error database is locked pada aplikasi yang ramai. Membuka koneksi SQLite sendiri murah karena tidak ada jabat tangan jaringan, sehingga pola buka-pakai-tutup per operasi tidak menambah beban yang berarti. Program yang melayani banyak permintaan sekaligus membagi satu koneksi bersama dengan pengamanan yang tepat, atau membuka koneksi baru per operasi dengan pola buka-pakai-tutup di atas. Dengan disiplin ini, transaksi pun pendek dan berurutan, sehingga basis data tetap responsif dan cadangan berjalan mulus seperti pada topik strategi backup.

Ringkasan#

  • SQL injection terjadi karena nilai masukan digabung ke teks query sebelum SQLite mem-parse pernyataannya.
  • Parameter binding mengirim nilai terpisah dari teks SQL, sehingga isi nilai tidak pernah dianggap bagian pernyataan dan kutip pada nilai tidak mengubah strukturnya.
  • Placeholder hanya menempati posisi nilai, sedangkan identifier yang dinamis disaring lewat daftar izin.
  • Koneksi dibuka sesaat sebelum dipakai, transaksinya diatur dengan with koneksi:, dan penutupannya lewat close() atau contextlib.closing.

Referensi#