SQL injection pertama kali didokumentasikan pada 1998 dan berada di peringkat #3 dalam OWASP Top 10 2021. Dua puluh tujuh tahun kemudian, ini tetap menjadi salah satu kerentanan yang paling banyak dieksploitasi. Alasannya bukan ketidaktahuan — kebanyakan developer tahu apa itu SQL injection. Alasannya adalah rasa puas: 'Saya menggunakan ORM, jadi saya aman.' Itu sebagian benar dan berbahaya secara keliru. ORM seperti Prisma, TypeORM, dan Sequelize mencegah injeksi dalam metode query standar, tetapi semuanya menyediakan celah raw query yang membawa kerentanan kembali jika digunakan secara tidak benar.
Metode query Prisma (findMany, findUnique, create, update, delete) menggunakan parameterized query secara internal — input pengguna masuk ke slot parameter, tidak pernah ke string SQL itu sendiri. Di mana Anda kehilangan perlindungan: $queryRaw dan $executeRaw Prisma dengan tag template literal, QueryBuilder TypeORM dengan string yang digabungkan, raw query Sequelize, dan setiap kali Anda membangun string klausa WHERE dari input pengguna dan meneruskannya ke fungsi raw query.
Pola injeksi yang saya temukan dalam code review: (1) Interpolasi string di $queryRaw — `prisma.$queryRaw('SELECT * FROM users WHERE email = ' + email)` dapat diinjeksi. Versi aman menggunakan tag template literal. (2) Dynamic ORDER BY — ORM biasanya tidak dapat memparameterkan nama kolom. `ORDER BY ${userInput}` dapat diinjeksi. Validasi nama kolom terhadap whitelist kolom sort yang diizinkan. (3) Nama tabel dinamis — jangan pernah membiarkan input pengguna menentukan nama tabel.
// DANGEROUS — string concatenation in raw query
const email = req.body.email // attacker sends: ' OR '1'='1
await prisma.$queryRaw('SELECT * FROM users WHERE email = ' + email)
// → dumps entire users table
// SAFE — tagged template literal (auto-parameterized by Prisma)
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`
// DANGEROUS — dynamic ORDER BY with user input
const sortColumn = req.query.sort // attacker sends: id; DROP TABLE users--
await prisma.$queryRaw`SELECT * FROM invoices ORDER BY ${sortColumn}`
// SAFE — allowlist validation for column names
const ALLOWED_SORT_COLUMNS = ['createdAt', 'amount', 'invoiceNumber', 'status'] as const
type SortColumn = typeof ALLOWED_SORT_COLUMNS[number]
function validateSortColumn(col: string): SortColumn {
if (!ALLOWED_SORT_COLUMNS.includes(col as SortColumn)) {
throw new BadRequestException(`Invalid sort column: ${col}`)
}
return col as SortColumn
}
const safeSort = validateSortColumn(req.query.sort as string)
// Use Prisma's safe orderBy instead of raw SQL
const invoices = await prisma.invoice.findMany({
orderBy: { [safeSort]: req.query.order === 'desc' ? 'desc' : 'asc' },
where: { userId: req.user.id }
})
// Testing with sqlmap
// sqlmap -u 'https://staging.api.com/invoices?sort=createdAt' \
// --cookie='Authorization=Bearer <token>' \
// --level=3 --risk=2 --batchDari pengalaman saya mengamankan sistem ERP: tambahkan aturan ESLint kustom ke pipeline CI Anda yang menandai penggunaan $queryRaw atau $executeRaw dengan pola non-template-literal. Aturan tersebut menangkap antipattern injeksi paling umum sebelum mencapai code review. Saya juga menjalankan sqlmap terhadap lingkungan staging setelah perubahan skema apa pun.
Ketika Anda benar-benar membutuhkan SQL mentah (fungsi window yang kompleks, fitur khusus database, query kritis performa), gunakan sintaks tag template literal Prisma yang secara otomatis memparameterkan semua nilai yang diinterpolasi. Untuk nama kolom dan tabel dinamis yang tidak dapat diparameterkan, implementasikan validasi allowlist yang ketat — jangan pernah gunakan validasi blocklist untuk kode kritis keamanan.
Saya menjalankan sqlmap terhadap setiap endpoint API staging yang menerima input pengguna sebelum rilis. Pemindaian dasar: `sqlmap -u 'https://staging.example.com/api/users?id=1' --cookie='auth=<token>' --level=3 --risk=2`. Untuk endpoint POST: `sqlmap -u 'https://staging.example.com/api/search' --data='query=test' --method=POST`. sqlmap secara otomatis menguji lusinan teknik dan payload injeksi.
Dalam sistem ERP klien yang saya warisi untuk pemeliharaan, saya menemukan endpoint pencarian yang membangun klausa WHERE dari parameter query URL: developer sebelumnya telah menggabungkan input pengguna ke dalam TypeORM QueryBuilder. Endpoint telah aktif selama 6 bulan. Saya mengujinya dengan injeksi dasar `' OR '1'='1` dan API mengembalikan semua catatan dalam tabel, melewati filter tenant. Tidak ada bukti telah dieksploitasi, tetapi paparannya nyata.
Injeksi first-order (input langsung ke query) adalah apa yang diuji kebanyakan developer. Injeksi second-order (input disimpan dengan aman, kemudian diambil dan digunakan tidak aman nanti) kurang dipahami dengan baik. Contoh: pengguna mendaftar dengan username `admin'--`. Pendaftaran menyimpan ini dengan aman melalui parameterized query. Kemudian, fitur panel admin mengambil username dan menggunakannya dalam string raw query untuk laporan. Injeksi menyala pada penggunaan kedua, bukan yang pertama.
Pencegahan SQL injection adalah satu lapisan keamanan database. Juga implementasikan: prinsip hak istimewa terkecil untuk pengguna database (pengguna API tidak boleh memiliki izin DROP TABLE atau CREATE USER), pengguna database terpisah per layanan, connection pooling dengan batas untuk mencegah DoS kelelahan koneksi, batas waktu query (matikan query yang berjalan lebih dari 30 detik), dan audit logging untuk tabel sensitif.
-- PostgreSQL security configuration
-- 1. Dedicated app user with minimal privileges
CREATE USER app_user WITH PASSWORD 'strong_password';
GRANT CONNECT ON DATABASE erp_db TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- Note: no DDL permissions (no CREATE, DROP, ALTER)
-- 2. Enable audit logging for sensitive tables
CREATE EXTENSION IF NOT EXISTS pgaudit;
-- postgresql.conf:
-- pgaudit.log = 'write, ddl'
-- pgaudit.log_relation = on
-- 3. Row-level security for multi-tenant data
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::UUID);
-- 4. Set statement timeout
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';Yang saya verifikasi di setiap deployment: semua input pengguna melalui parameterized query ORM atau divalidasi terhadap allowlist sebelum penggunaan raw query, pengguna database memiliki SELECT/INSERT/UPDATE/DELETE saja (tidak ada DDL), connection string dalam variabel lingkungan (tidak dalam kode), pg_hba.conf membatasi akses database ke IP server aplikasi saja, pgaudit diaktifkan pada tabel keuangan, batas waktu query diatur ke 30 detik. Menjalankan sqlmap terhadap staging sebelum setiap rilis adalah langkah verifikasi final.
Sumber & Bacaan Lanjutan