Section user management dan keamanan sudah membuktikan di level SQL bahwa payload injeksi kehilangan kekuatannya begitu nilai dikirim terpisah dari struktur statement — lewat siklus PREPARE-EXECUTE di dalam sesi. Sekarang kita berpindah ke sisi tempat injeksi benar-benar lahir: kode aplikasi yang menerima input pengguna. Dalam chapter ini kita menjalankan dua program Python sungguhan — satu membuktikan kebocoran query rakit-rakit di depan mata kita, satu lagi memperlihatkan perbedaan nyata query yang sama dengan placeholder — lalu memetakan sintaks placeholder di empat driver, mengeksekusi batch INSERT dengan executemany(), dan membedah lapisan yang membedakan prepared statement sisi server dari interpolasi sisi klien.
Kebocoran yang Klasik: Merakit String SQL #
Bentuk kerentanannya selalu sama di semua bahasa: string SQL dirakit dengan menggabungkan input pengguna langsung ke teksnya. Bayangkan halaman login yang mencari pelanggan berdasar email:
sql = "SELECT id, nama FROM pelanggan WHERE email = '" + email + "'"Query ini benar untuk email yang sopan. Bila email berisi [email protected], hasilnya satu baris. Tetapi input pengguna tidak dijamin sopan — seorang penyerang cukup mengisi kolom email dengan payload ' OR 1=1 -- . String hasil rakitan menjadi:
SELECT id, nama FROM pelanggan WHERE email = '' OR 1=1 -- 'Tiga kejadian kecil di dalamnya: tanda kutip pembuka menutup string kosong, OR 1=1 menjadikan kondisi selalu benar untuk semua baris, dan -- mengomentari sisa teks supaya tanda kutip penutup tidak memicu error sintaks. Kondisi WHERE yang tadinya menyaring satu orang kini loloskan seluruh tabel — login palsu berhasil. Kita tidak perlu mengarang skenario ini: sebentar lagi program pertama kita akan menjalankannya sungguhan.
Menyiapkan Data Latihan #
Demo kita memakai dua tabel di database belajar_app: pelanggan untuk skenario login, produk untuk eksekusi batch. Skemanya dibangun lewat klien supaya chapter ini mandiri:
DROP DATABASE IF EXISTS belajar_app;
CREATE DATABASE belajar_app;
USE belajar_app;
CREATE TABLE pelanggan (
id INT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE
);
INSERT INTO pelanggan (id, nama, email) VALUES
(1, 'Budi Santoso', '[email protected]'),
(2, 'Siti Aminah', '[email protected]');
SELECT id, nama, email FROM pelanggan ORDER BY id;Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changed
Query OK, 0 rows affected (0.00 sec)
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0
+----+--------------+------------------+
| id | nama | email |
+----+--------------+------------------+
| 1 | Budi Santoso | [email protected] |
| 2 | Siti Aminah | [email protected] |
+----+--------------+------------------+
2 rows in set (0.00 sec)CREATE TABLE produk (
id INT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
harga INT NOT NULL
);
INSERT INTO produk (id, nama, harga) VALUES
(1, 'Kopi Toraja', 52000),
(2, 'Teh Melati', 28000),
(3, 'Gula Aren', 32000);Dua baris pelanggan inilah medan uji kita: satu email sah milik Budi, dan satu payload yang akan kita lepaskan ke query rakit-rakit.
Demo Nyata: Rentan versus Aman #
Berikut aman.py — tersimpan di bundle chapter (bagian Berkas Sumber). Program ini memakai data latihan kita sendiri, jadi tidak ada yang dirusak; tugasnya murni membandingkan dua gaya menulis query yang sama persis fungsinya:
"""aman.py — membandingkan query rakit-rakit (rentan) dengan parameterized (aman).
Jalankan dari folder chapter: python3 aman.py
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""
import os
import pymysql
port = int(os.environ.get("DB_PORT", "3306"))
pengaturan = {
"host": "127.0.0.1",
"user": "root",
"password": "",
"database": "belajar_app",
"port": port,
}
email_login = "[email protected]"
payload = "' OR 1=1 -- "
with pymysql.connect(**pengaturan) as koneksi:
with koneksi.cursor() as kursor:
sql_rentan = (
"SELECT id, nama FROM pelanggan WHERE email = '"
+ email_login
+ "'"
)
kursor.execute(sql_rentan)
print("query rentan, email sah ->", len(kursor.fetchall()), "baris")
sql_bocor = (
"SELECT id, nama FROM pelanggan WHERE email = '"
+ payload
+ "'"
)
kursor.execute(sql_bocor)
print("query rentan, payload injeksi ->", len(kursor.fetchall()), "baris")
sql_aman = "SELECT id, nama FROM pelanggan WHERE email = %s"
kursor.execute(sql_aman, (payload,))
print("query parameterized, payload ->", len(kursor.fetchall()), "baris")Menjalankannya dari terminal (dengan DB_PORT seperti biasa; di komputer Anda cukup python3 aman.py):
-- @test-bash
DB_PORT=3306 python3 content/mysql/integrasi-aplikasi/prepared-statements-dalam-aplikasi/aman.pyOutput yang Dihasilkan:
query rentan, email sah -> 1 baris
query rentan, payload injeksi -> 2 baris
query parameterized, payload -> 0 barisTiga baris hasilnya adalah cerita lengkap bab ini. Baris pertama: query rentan dengan email sah mengembalikan 1 baris — perilaku yang diinginkan. Baris kedua: query yang sama dengan payload injeksi mengembalikan 2 baris — seluruh isi tabel pelanggan — karena OR 1=1 menetralkan filter. Baris ketiga: query parameterized dengan payload yang sama persis mengembalikan 0 baris — MySQL mencari pelanggan yang emailnya benar-benar bernilai ' OR 1=1 -- , tidak menemukannya, dan tidak terjadi apa-apa. Payload di tangan parameter bukan senjata, hanya teks.
Kenapa Placeholder Itu Aman #
Mekanismenya bekerja di lapisan protokol, bukan di lapisan teks. Saat kita memanggil kursor.execute(sql_aman, (payload,)), driver mengirim dua hal yang terpisah: teks statement dengan placeholder kosong, dan daftar nilai sebagai data terstruktur. Server mem-parsing struktur statement sekali; nilai tidak pernah “dimasukkan” ke teks SQL — server menyimpannya sebagai nilai dan membandingkannya sebagai nilai. Tidak ada momen di mana payload bisa ditafsirkan sebagai SQL, sebab payload tidak pernah lewat jalur yang menafsirkan SQL.
Bandingkan dengan trik yang kelihatannya mirip: escaping — mengganti tanda kutip menjadi \' lalu menggabungkan string. Escaping memang bekerja, tetapi bergantung pada aturan yang benar untuk charset koneksi dan pada ketelitian implementasi driver; sejarah keamanan web penuh bug escaping di edge case charset. Placeholder memindahkan seluruh masalah keluar dari ranah teks. Aturan praktisnya satu kalimat: bila ada input, wajib placeholder — bukan escaping manual, bukan “sanitasi” buatan sendiri.
Placeholder di Setiap Driver #
Masing-masing driver yang kita bahas di section ini punya sintaks placeholder sendiri:
| Driver | Placeholder | Contoh pemanggilan |
|---|---|---|
| PyMySQL (Python) | %s | cursor.execute("... WHERE id = %s", (1,)) |
| mysql2 (Node.js) | ? | pool.query('... WHERE id = ?', [1]) |
| mysqli (PHP) | ? + tipe di bind_param | $stmt->bind_param('i', $id); |
| database/sql (Go) | ? | db.Query("... WHERE id = ?", 1) |
Nilai selalu dikirim sebagai unit terpisah dari teks statement. Padanan PHP-nya pernah kita tulis di chapter MySQL dan PHP (prepare + bind_param('i', ...) dengan huruf tipe), dan padanan Go-nya di chapter MySQL dan Go (db.Query dengan argumen setelah teks SQL). Bentuknya beda-beda, semangatnya satu.
Eksekusi Berulang: executemany #
Pola parameterized menyimpan satu keuntungan ekstra saat satu statement harus berjalan berkali-kali dengan data berbeda. Daripada memanggil execute() dalam perulangan, PyMySQL menyediakan executemany() — kirim template sekali, kirim daftar baris sekali:
"""executemany.py — INSERT banyak baris dalam satu panggilan executemany().
Jalankan dari folder chapter: python3 executemany.py
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""
import os
import pymysql
port = int(os.environ.get("DB_PORT", "3306"))
produk_baru = [
(4, "Susu Kental", 38000),
(5, "Kopi Robusta", 48000),
(6, "Kopi Luwak", 120000),
]
with pymysql.connect(
host="127.0.0.1",
user="root",
password="",
database="belajar_app",
port=port,
) as koneksi:
with koneksi.cursor() as kursor:
kursor.executemany(
"INSERT INTO produk (id, nama, harga) VALUES (%s, %s, %s)",
produk_baru,
)
koneksi.commit()
print("executemany memasukkan", kursor.rowcount, "baris")
with koneksi.cursor() as kursor:
kursor.execute("SELECT id, nama, harga FROM produk ORDER BY id")
print("isi tabel produk sekarang:")
for id_produk, nama, harga in kursor.fetchall():
print(f" {id_produk} | {nama} | {harga}")Menjalankannya:
-- @test-bash
DB_PORT=3306 python3 content/mysql/integrasi-aplikasi/prepared-statements-dalam-aplikasi/executemany.pyOutput yang Dihasilkan:
executemany memasukkan 3 baris
isi tabel produk sekarang:
1 | Kopi Toraja | 52000
2 | Teh Melati | 28000
3 | Gula Aren | 32000
4 | Susu Kental | 38000
5 | Kopi Robusta | 48000
6 | Kopi Luwak | 120000Bedah singkatnya: executemany(template, produk_baru) mengisi placeholder %s untuk setiap tuple di daftar. Khusus untuk INSERT ... VALUES, PyMySQL bahkan menggabungkan baris-barusnya menjadi satu statement INSERT multi-baris — bentuk yang sama dengan yang kita tulis manual di section DML — sehingga satu panggilan jaringan cukup untuk semua data; rowcount melaporkan total 3 baris. Alternatif perulangan manual (for baris in produk_baru: kursor.execute(...)) menghasilkan isi tabel yang sama, tetapi membayar tiga perjalanan bolak-balik klien-server — di jaringan nyata dengan ratusan ribu baris, selisihnya berkali lipat. Setelahnya, koneksi.commit() tetap wajib: executemany bukan transaksi otomatis.
Sisi Server versus Sisi Klien #
Di mana sebenarnya “persiapan” itu terjadi? Ada dua keluarga implementasi, dan keduanya aman dari injeksi — bedanya di protokol dan performa.
| Keluarga | Cara kerja | Contoh |
|---|---|---|
| Prepared sisi server | teks dengan placeholder dikirim ke server, di-parse sekali, lalu dieksekusi berulang lewat protokol biner | PREPARE/EXECUTE di SQL, stmt mysqli, db.Prepare Go, conn.execute() mysql2 |
| Interpolasi sisi klien | driver meng-escape nilai dan menyusun statement lengkap di sisi aplikasi, lalu mengirimnya sebagai teks | cursor.execute() PyMySQL, pool.query() mysql2 |
PyMySQL termasuk keluarga kedua: %s diisi driver dengan nilai yang sudah di-escape — aman karena escaping dikerjakan library dengan aturan charset yang benar, bukan oleh kita. mysql2 memiliki keduanya: pool.query() menginterpolasi di klien, sedangkan conn.execute() mengirim statement ke server sebagai prepared statement sungguhan lewat binary protocol (placeholder tetap ? di keduanya). Pilihan execute() membayar satu perjalanan tambahan saat pertama kali menyiapkan statement, lalu hemat di eksekusi berikutnya karena teks SQL tidak perlu dikirim ulang dan hasil dikirim dalam format biner yang lebih ringkas.
Kapan Prepared Tidak Diperlukan #
Prepared statement sisi server paling berpayback saat statement yang sama dieksekusi berulang kali — ratusan request per detik dengan template identis. Sebaliknya, untuk query yang berjalan sekali seumur hidup proses (migrasi, laporan harian, query admin), biaya penyiapan tidak kembali apa-apa: mengirim teks lengkap sekali — interpolasi klien — adalah pilihan yang lebih sederhana dan sama amannya. MySQL versi modern juga tidak lagi punya query cache untuk diandalkan sebagai alasan (fitur itu sudah dihapus), jadi ukurannya murni: sering dieksekusi → siapkan di server; jarang → kirim saja.
Membersihkan Latihan #
-- @test-bash
mysql -h 127.0.0.1 -P 3306 -u root -e "DROP DATABASE IF EXISTS belajar_app;"Referensi #
- SQL Prepared Statements — MySQL 8.4 Reference Manual
- INSERT Statement — MySQL 8.4 Reference Manual
- Connection Options — MySQL 8.4 Reference Manual
Struktur tabel terkunci dengan aman lewat placeholder — tetapi bagaimana tabel itu sendiri berubah dari waktu ke waktu: kolom baru ditambah, tipe diperbaiki, index dipasang, di laptop satu orang dan di server produksi yang lain? Jawabannya bukan pengeditan manual dengan ALTER TABLE asal-jalan, melainkan disiplin file bernomor: Database Migrations, chapter berikutnya.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
aman.py
Unduh berkas"""aman.py — membandingkan query rakit-rakit (rentan) dengan parameterized (aman).
Jalankan dari folder chapter: python3 aman.py
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""
import os
import pymysql
port = int(os.environ.get("DB_PORT", "3306"))
pengaturan = {
"host": "127.0.0.1",
"user": "root",
"password": "",
"database": "belajar_app",
"port": port,
}
email_login = "[email protected]"
payload = "' OR 1=1 -- "
with pymysql.connect(**pengaturan) as koneksi:
with koneksi.cursor() as kursor:
sql_rentan = (
"SELECT id, nama FROM pelanggan WHERE email = '"
+ email_login
+ "'"
)
kursor.execute(sql_rentan)
print("query rentan, email sah ->", len(kursor.fetchall()), "baris")
sql_bocor = (
"SELECT id, nama FROM pelanggan WHERE email = '"
+ payload
+ "'"
)
kursor.execute(sql_bocor)
print("query rentan, payload injeksi ->", len(kursor.fetchall()), "baris")
sql_aman = "SELECT id, nama FROM pelanggan WHERE email = %s"
kursor.execute(sql_aman, (payload,))
print("query parameterized, payload ->", len(kursor.fetchall()), "baris")
executemany.py
Unduh berkas"""executemany.py — INSERT banyak baris dalam satu panggilan executemany().
Jalankan dari folder chapter: python3 executemany.py
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""
import os
import pymysql
port = int(os.environ.get("DB_PORT", "3306"))
produk_baru = [
(4, "Susu Kental", 38000),
(5, "Kopi Robusta", 48000),
(6, "Kopi Luwak", 120000),
]
with pymysql.connect(
host="127.0.0.1",
user="root",
password="",
database="belajar_app",
port=port,
) as koneksi:
with koneksi.cursor() as kursor:
kursor.executemany(
"INSERT INTO produk (id, nama, harga) VALUES (%s, %s, %s)",
produk_baru,
)
koneksi.commit()
print("executemany memasukkan", kursor.rowcount, "baris")
with koneksi.cursor() as kursor:
kursor.execute("SELECT id, nama, harga FROM produk ORDER BY id")
print("isi tabel produk sekarang:")
for id_produk, nama, harga in kursor.fetchall():
print(f" {id_produk} | {nama} | {harga}")