Lewati ke konten
CodeAuditAgent
Semua artikel

Mencegah SQL Injection di Node.js, Python, dan Go

SQL rentan dan perbaikannya untuk pg, Prisma, psycopg, SQLAlchemy, dan database/sql Go, plus ORDER BY dinamis dan daftar IN yang aman. Mengacu ke CWE-89.

· 7 menit baca · Lina Source LLC

SQL injection (CWE-89) adalah salah satu bug tertua di web dan masih salah satu yang paling merusak. Penyebabnya tidak pernah berubah: input pengguna ditempelkan ke dalam teks query, sehingga database tidak bisa membedakan data dari kode. Perbaikannya juga tidak berubah: kirim query dan nilainya secara terpisah, lalu biarkan driver yang mengikatnya.

Yang berubah adalah di mana bug ini hidup. Sebagian besar tim memakai ORM untuk query sehari-hari, jadi injection kini muncul di jalan keluar daruratnya: query mentah yang ditulis untuk sebuah laporan, endpoint pencarian dengan pengurutan dinamis, skrip migrasi, perkakas admin. Panduan ini menelusuri versi rentan dan versi perbaikannya di tiga ekosistem, lalu membahas dua kasus yang tidak bisa diselesaikan oleh parameter saja.

Dampaknya jarang terbatas pada satu tabel. Satu query yang bisa diinjeksi biasanya berjalan dengan seluruh hak akses database milik aplikasi, sehingga ia bisa membaca data setiap pelanggan, termasuk hash kata sandi dan token API, mengubah baris, dan pada sebagian database menjangkau sistem file atau server lain. Teknik blind mengekstrak data satu bit demi satu bit bahkan ketika hasil query tidak pernah ditampilkan kepada pengguna, jadi endpoint yang hanya mengembalikan true atau false tetap bisa dieksploitasi.

Mengapa parameter berhasil dan escaping tidak

Dengan query terparameterisasi, driver mengirim teks SQL berisi placeholder, dan nilainya berjalan sebagai data terpisah. Database mem-parsing statement sebelum ia pernah melihat nilainya, sehingga tanda kutip atau titik koma di dalam input hanyalah karakter di dalam sebuah string. Escaping manual berusaha membuat sebuah nilai aman untuk ditempelkan ke teks SQL, dan ia gagal pada encoding, konteks numerik, identifier, dan setiap kasus tepi yang tidak Anda pikirkan. Jangan meng-escape; lakukan binding.

Node.js: pg dan Prisma

Dengan node-postgres, pola yang rentan adalah template literal yang diteruskan ke query. Perbaikannya adalah placeholder $1 dan sebuah array nilai. Prisma aman secara default, tetapi ia punya dua API mentah, dan hanya satu di antaranya yang aman dengan interpolasi.

import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";

const pool = new Pool();
const prisma = new PrismaClient();

// Rentan: input menjadi bagian dari teks SQL
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);

// Diperbaiki: nilai di-bind sebagai parameter
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);

// Prisma: $queryRaw adalah tagged template, interpolasi menjadi parameter
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;

// Prisma: $queryRawUnsafe dengan konkatenasi bisa diinjeksi
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = '" + email + "'"
);

// Jika terpaksa memakai $queryRawUnsafe, kirim nilai sebagai argumen
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = $1",
  email
);

Sebuah jebakan halus di Prisma: menyusun string query terlebih dahulu lalu meneruskannya ke $queryRaw menghilangkan perlindungannya. Keamanannya berasal dari sintaks tagged template. Jika Anda perlu menyusun potongan query, gunakan Prisma.sql dan Prisma.join, yang menjaga nilai tetap sebagai parameter. Aturan yang sama berlaku untuk pustaka tagged template lain seperti postgres.js dan slonik: tag itulah yang membuat interpolasi aman, sehingga string query yang dirakit lebih dulu lalu dikirim sebagai nilai biasa akan melewatinya.

Python: psycopg dan SQLAlchemy

Di Python, perkakas yang berbahaya adalah f-string, operator %, dan str.format yang diterapkan pada SQL. psycopg memakai placeholder %s, yang terlihat seperti pemformatan string padahal bukan: nilainya masuk lewat argumen kedua execute, tidak pernah melalui operator %. Konstruksi text() milik SQLAlchemy aman saat Anda memakai bind parameter bernama dan tidak aman saat Anda memformat nilai ke dalam string-nya.

Satu detail psycopg sering menjebak orang: placeholder-nya adalah %s (atau %(name)s untuk nilai bernama) apa pun tipe kolomnya, dan Anda tidak pernah menambahkan tanda kutip di sekelilingnya. Menulis '%s' dengan tanda kutip mengubah parameter itu kembali menjadi bagian dari literal string dan merusak query. Hal yang sama berlaku untuk parameter bernama di SQLAlchemy: :status, bukan ':status'.

from sqlalchemy import text

# psycopg: rentan
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")

# psycopg: diperbaiki, nilai dikirim terpisah
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))

# SQLAlchemy text(): rentan
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))

# SQLAlchemy text(): diperbaiki dengan bind parameter
conn.execute(
    text("SELECT id FROM orders WHERE status = :status"),
    {"status": status},
)

Go: database/sql

database/sql milik Go mendukung placeholder secara bawaan, tetapi sintaks placeholder-nya bergantung pada driver: $1 untuk driver PostgreSQL seperti pgx, dan ? untuk MySQL dan SQLite. Pola yang rentan adalah fmt.Sprintf atau konkatenasi string untuk menyusun klausa WHERE. Karena Go bertipe statis, ada godaan untuk menganggap parameter int tidak mungkin berbahaya. Itu benar untuk nilainya sendiri, tetapi kebiasaan menyusun query dengan Sprintf menular ke parameter string di sebelahnya, jadi pertahankan setiap query tetap memakai placeholder.

// Rentan: konkatenasi string
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)

// Diperbaiki: placeholder plus argumen (sintaks PostgreSQL; MySQL memakai ?)
var u User
err = db.QueryRowContext(ctx,
    "SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
    // tidak ditemukan
}

Jalan keluar darurat pada ORM

ORM melindungi query builder-nya, bukan setiap metode pada ORM itu. Inilah tempat-tempat yang perlu diperiksa lebih dulu di kode mana pun:

  • Prisma: $queryRawUnsafe dan $executeRawUnsafe, serta setiap pemanggilan $queryRaw yang menerima string yang sudah dirakit alih-alih tagged template.
  • Sequelize dan TypeORM: sequelize.query, pemanggilan .where() pada query builder yang diberi string hasil konkatenasi, dan klausa order atau group mentah.
  • Knex: knex.raw dan whereRaw dengan nilai yang diinterpolasi alih-alih binding ?.
  • SQLAlchemy: text() dengan f-string, serta literal_column() atau nama kolom yang diambil dari input.
  • Django: .raw(), .extra(), dan cursor.execute dengan string yang diformat.
  • GORM: Where, Order, dan Raw yang dipanggil dengan keluaran fmt.Sprintf alih-alih argumen.

ORDER BY dinamis: parameter tidak bisa menolong

Placeholder mengikat nilai, bukan identifier. Anda tidak bisa menulis ORDER BY $1 lalu mengirim nama kolom; database akan mengurutkan berdasarkan sebuah konstanta. Jadi tabel yang bisa diurutkan cenderung mendorong developer kembali ke penyusunan string, dan di situlah injection kembali muncul. Keterbatasan yang sama berlaku untuk nama tabel, nama skema, dan kata kunci SQL seperti ASC dan DESC.

Pola yang aman adalah allowlist yang memetakan kunci pengurutan yang dilihat pengguna ke nama kolom yang sudah dikenal. Input memilih sebuah entri; ia tidak pernah menjadi SQL. Arah pengurutan mendapat perlakuan yang sama: petakan ke ASC atau DESC yang tetap.

var sortColumns = map[string]string{
    "created": "created_at",
    "name":    "name",
    "price":   "price_cents",
}

col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
    col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
    dir = "DESC"
}

// Aman: col dan dir hanya bisa bernilai sesuatu dari kode di atas
query := fmt.Sprintf(
    "SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
    col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)

Jika Anda benar-benar membutuhkan identifier dinamis yang tidak bisa di-allowlist, gunakan fasilitas pengutipan identifier milik driver, bukan buatan Anda sendiri. Di psycopg itu adalah sql.SQL(...).format(sql.Identifier(name)); di pgx itu adalah pgx.Identifier{name}.Sanitize(). Allowlist tetap lebih baik, karena pengutipan membuat nama itu aman tetapi belum tentu valid atau diizinkan. Identifier yang dikutip tetap memungkinkan pemanggil mengurutkan atau memfilter berdasarkan kolom mana pun di tabel, termasuk kolom yang tidak pernah Anda maksudkan untuk dipaparkan, seperti hash kata sandi atau skor internal. Mengurutkan berdasarkan kolom tersembunyi bisa membocorkan nilainya melalui urutan hasil, satu perbandingan demi satu perbandingan.

Daftar IN tanpa penyusunan string

Memfilter berdasarkan daftar ID adalah alasan umum lain untuk konkatenasi. Setiap stack besar punya cara aman untuk melakukannya:

  • PostgreSQL dengan pg atau pgx: kirim seluruh daftar sebagai satu parameter array dan tulis WHERE id = ANY($1). Driver mengirimkannya sebagai array Postgres.
  • Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` melebar menjadi satu placeholder per elemen.
  • SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), lalu kirim sebuah list.
  • Go dengan MySQL: bangkitkan string placeholder dari panjang daftar (strings.Repeat("?,", n) yang dipangkas) dan kirim nilainya sebagai argumen. Hanya placeholder yang dibangkitkan, tidak pernah nilainya.
  • Tolak daftar kosong sebelum query berjalan, dan batasi ukuran daftar agar endpoint tidak bisa dipakai menyusun statement raksasa.

Pertahanan berlapis

Parameterisasi adalah perbaikannya. Hal-hal berikut mengecilkan radius ledakan ketika satu query lolos:

  • Sambungkan dengan role database yang hanya punya hak akses yang benar-benar dibutuhkan aplikasi. Aplikasi web jarang butuh DROP, ALTER, atau akses ke skema lain.
  • Jangan mengembalikan error database mentah ke klien. Injection berbasis error bergantung pada kemampuan melihatnya.
  • Validasi tipe di batas sistem: ID yang seharusnya berupa integer atau UUID harus diparsing sebagai itu sebelum mencapai lapisan data.
  • Setel statement timeout, yang membatasi injection blind berbasis waktu sekaligus query yang lepas kendali.

Menemukan yang sudah ada di kode Anda

Mulailah dengan mencari jalan keluar darurat yang disebutkan di atas, lalu kata kunci SQL yang berdekatan dengan pemformatan string: SELECT atau WHERE di dalam template literal, f-string, Sprintf, atau konkatenasi +. Setiap hasil pencarian entah sudah di-bind dengan benar, dibangun dari allowlist, atau memang sebuah bug. Jangan melewatkan jalur kode yang tampak internal: importer CSV, cron job, dan dasbor admin sering menerima input yang aslinya berasal dari pengguna, hanya berjarak satu langkah. Nilai yang dibaca kembali dari database Anda sendiri bisa membawa payload yang dulu disimpan dengan aman lalu ditempelkan ke sebuah query belakangan, yang dikenal sebagai second-order injection. CodeAuditAgent melakukan penelusuran lintas file ini untuk repositori GitHub publik dan kode yang ditempel, lalu melaporkan temuan CWE-89 lengkap dengan query yang dikutip, bagaimana input mencapainya, dan versi yang sudah di-patch.

Aturan yang perlu Anda bawa pulang singkat saja: nilai masuk lewat parameter, identifier berasal dari allowlist, dan tidak ada apa pun dari permintaan yang pernah ditempelkan ke teks SQL.