CodeAuditAgent
Tüm yazılar
  • Güvenlik
  • SQL
  • OWASP

Node.js, Python ve Go'da SQL Enjeksiyonunu Önlemek

pg, Prisma, psycopg, SQLAlchemy ve Go database/sql için açıklı ve düzeltilmiş SQL örnekleri, güvenli dinamik ORDER BY ve IN listeleri. CWE-89 ile eşlenmiştir.

· 7 dk okuma · Lina Source LLC

SQL enjeksiyonu (CWE-89), web'in en eski hatalarından biri ve hâlâ en yıkıcılarından biridir. Nedeni hiç değişmedi: kullanıcı girdisi sorgunun metnine yapıştırılır, bu yüzden veritabanı veriyi koddan ayırt edemez. Çözüm de değişmedi: sorguyu ve değerleri ayrı ayrı gönderin, bağlama işini sürücüye bırakın.

Değişen, hatanın yaşadığı yerdir. Çoğu ekip günlük sorgular için bir ORM kullanır; bu yüzden enjeksiyon artık kaçış kapılarında ortaya çıkıyor: bir rapor için yazılmış ham sorgu, dinamik sıralamalı arama endpoint'i, migration betiği, yönetici aracı. Bu rehber üç ekosistemde açıklı ve düzeltilmiş sürümleri ele alıyor, ardından yalnızca parametrelerin çözemediği iki durumu inceliyor.

Etkisi nadiren tek bir tabloyla sınırlı kalır. Enjeksiyona açık tek bir sorgu genellikle uygulamanın tüm veritabanı yetkileriyle çalışır; dolayısıyla parola hash'leri ve API token'ları dahil her müşterinin verisini okuyabilir, satırları değiştirebilir ve bazı veritabanlarında dosya sistemine veya başka sunuculara ulaşabilir. Kör (blind) teknikler, sorgunun sonucu kullanıcıya hiç gösterilmese bile veriyi bit bit çıkarır; yani yalnızca doğru ya da yanlış döndüren bir endpoint bile istismar edilebilir.

Parametreler neden işe yarar, kaçışlama neden yaramaz

Parametreli bir sorguda sürücü, SQL metnini yer tutucularla gönderir ve değerler ayrı veri olarak iletilir. Veritabanı ifadeyi değeri görmeden önce ayrıştırır; bu yüzden girdideki bir tırnak veya noktalı virgül, bir dizedeki sıradan bir karakterden ibarettir. Elle kaçışlama ise bir değeri SQL metnine yapıştırılabilecek kadar güvenli hâle getirmeye çalışır ve karakter kodlamalarında, sayısal bağlamlarda, tanımlayıcılarda ve aklınıza gelmeyen her uç durumda başarısız olur. Kaçışlamayın; bağlayın.

Node.js: pg ve Prisma

node-postgres'te açıklı kalıp, query'ye verilen bir template literal'dir. Çözüm, bir $1 yer tutucusu ve bir değer dizisidir. Prisma varsayılan olarak güvenlidir ama iki ham API'si vardır ve bunlardan yalnızca biri interpolasyonla güvenle kullanılabilir.

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

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

// Vulnerable: input becomes part of the SQL text
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);

// Fixed: value is bound as a parameter
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);

// Prisma: $queryRaw is a tagged template, interpolations become parameters
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;

// Prisma: $queryRawUnsafe with concatenation is injectable
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = '" + email + "'"
);

// If you must use $queryRawUnsafe, pass values as arguments
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = $1",
  email
);

İnce bir Prisma tuzağı: sorgu dizesini önce oluşturup $queryRaw'a vermek korumayı ortadan kaldırır. Güvenlik, tagged template sözdiziminden gelir. Parçaları birleştirmeniz gerekiyorsa değerleri parametre olarak tutan Prisma.sql ve Prisma.join'i kullanın. Aynı kural postgres.js ve slonik gibi diğer tagged template kütüphaneleri için de geçerlidir: interpolasyonu güvenli kılan tag'in kendisidir; önceden birleştirilip düz bir değer olarak verilen sorgu dizesi bu korumayı atlatır.

Python: psycopg ve SQLAlchemy

Python'da tehlikeli araçlar, SQL'e uygulanan f-string'ler, % operatörü ve str.format'tır. psycopg, dize biçimlendirmeye benzeyen ama öyle olmayan %s yer tutucularını kullanır: değerler asla % operatörüyle değil, execute'un ikinci argümanıyla verilir. SQLAlchemy'nin text() yapısı, adlandırılmış bind parametreleriyle kullanıldığında güvenli, değerleri dizeye biçimlendirdiğinizde güvensizdir.

Bir psycopg ayrıntısı sık sık insanları şaşırtır: yer tutucu, sütun tipinden bağımsız olarak %s'dir (adlandırılmış değerler için %(name)s) ve etrafına asla tırnak eklenmez. Tırnaklı '%s' yazmak, parametreyi yeniden bir string literal'in parçası hâline getirir ve sorguyu bozar. SQLAlchemy'deki adlandırılmış parametreler için de durum aynıdır: ':status' değil, :status.

from sqlalchemy import text

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

# psycopg: fixed, values passed separately
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))

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

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

Go: database/sql

Go'nun database/sql paketi yer tutucuları doğal olarak destekler, ancak yer tutucu sözdizimi sürücüye bağlıdır: pgx gibi PostgreSQL sürücüleri için $1, MySQL ve SQLite için ?. Açıklı kalıp, WHERE koşulunu fmt.Sprintf veya dize birleştirmeyle oluşturmaktır. Go statik tipli olduğu için int bir parametrenin tehlikeli olamayacağını varsaymak cazip gelir. Bu, değerin kendisi için doğrudur; ancak sorguları Sprintf ile oluşturma alışkanlığı hemen yanındaki string parametrelere de yayılır, bu yüzden her sorguyu yer tutucularla yazın.

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

// Fixed: placeholder plus argument (PostgreSQL syntax; MySQL uses ?)
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) {
    // not found
}

ORM kaçış kapıları

ORM'ler ORM'in her metodunu değil, sorgu oluşturucuyu korur. Herhangi bir kod tabanında ilk bakılacak yerler şunlardır:

  • Prisma: $queryRawUnsafe ve $executeRawUnsafe ile tagged template yerine önceden oluşturulmuş bir dize alan her $queryRaw çağrısı.
  • Sequelize ve TypeORM: sequelize.query, birleştirilmiş bir dize verilen query builder .where() çağrıları ve ham order veya group ifadeleri.
  • Knex: ? bağlamaları yerine interpolasyonlu değerlerle kullanılan knex.raw ve whereRaw.
  • SQLAlchemy: f-string'lerle kullanılan text() ile girdiden alınan literal_column() veya sütun adları.
  • Django: .raw(), .extra() ve biçimlendirilmiş dizelerle kullanılan cursor.execute.
  • GORM: argümanlar yerine fmt.Sprintf çıktısıyla çağrılan Where, Order ve Raw.

Dinamik ORDER BY: parametreler yardımcı olamaz

Yer tutucular tanımlayıcıları değil, değerleri bağlar. ORDER BY $1 yazıp bir sütun adı veremezsiniz; veritabanı sabit bir değere göre sıralar. Bu yüzden sıralanabilir bir tablo geliştiricileri yeniden dize oluşturmaya iter ve enjeksiyon da tam burada geri döner. Aynı sınırlama tablo adları, şema adları ve ASC ile DESC gibi SQL anahtar sözcükleri için de geçerlidir.

Güvenli kalıp, kullanıcıya dönük sıralama anahtarlarını bilinen sütun adlarına eşleyen bir izin listesidir (allowlist). Kullanıcı girdisi listeden yalnızca bir kaydı seçer; asla SQL'e dönüşmez. Sıralama yönü de aynı şekilde ele alınır: sabit bir ASC veya DESC değerine eşlenir.

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"
}

// Safe: col and dir can only be values from the code above
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)

İzin listesine alınamayan dinamik bir tanımlayıcıya gerçekten ihtiyacınız varsa kendi yönteminizi değil, sürücünün tanımlayıcı tırnaklama özelliğini kullanın. psycopg'de bu sql.SQL(...).format(sql.Identifier(name)), pgx'te pgx.Identifier{name}.Sanitize()'dır. Yine de izin listesi daha iyidir; çünkü tırnaklama adı güvenli kılar ama geçerli veya izin verilmiş olmasını garanti etmez. Tırnaklanmış bir tanımlayıcı, çağıranın tablodaki herhangi bir sütuna, hatta parola hash'i veya dahili bir skor gibi hiç açığa çıkarmak istemediğiniz sütunlara göre sıralama veya filtreleme yapmasına izin verir. Gizli bir sütuna göre sıralamak, değerlerini sonuçların sırası üzerinden, her seferinde bir karşılaştırmayla sızdırabilir.

Dize oluşturmadan IN listeleri

Bir ID listesine göre filtrelemek, dize birleştirmenin bir diğer yaygın bahanesidir. Her büyük teknoloji yığınının bunu güvenle yapmanın bir yolu vardır:

  • pg veya pgx ile PostgreSQL: listenin tamamını tek bir dizi parametresi olarak verin ve WHERE id = ANY($1) yazın. Sürücü bunu bir Postgres dizisi olarak gönderir.
  • Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` her eleman için bir yer tutucuya genişler.
  • SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), ardından bir liste verin.
  • MySQL ile Go: yer tutucu dizesini liste uzunluğundan üretin (sonu kırpılmış strings.Repeat("?,", n)) ve değerleri argüman olarak verin. Değerler asla üretilmez, yalnızca yer tutucular üretilir.
  • Boş listeleri sorgu çalışmadan önce reddedin ve endpoint'in devasa ifadeler oluşturmak için kullanılamaması için liste boyutunu sınırlayın.

Derinlemesine savunma

Asıl çözüm parametrelemedir. Aşağıdakiler ise bir sorgu gözden kaçtığında hasarın boyutunu küçültür:

  • Yalnızca uygulamanın ihtiyaç duyduğu yetkilere sahip bir veritabanı rolüyle bağlanın. Web uygulamasının DROP, ALTER veya diğer şemalara erişime ihtiyacı nadiren olur.
  • Ham veritabanı hatalarını istemcilere döndürmeyin. Hata tabanlı enjeksiyon bu hataları görebilmeye dayanır.
  • Tipleri sınırda doğrulayın: integer veya UUID olması gereken bir ID, veri katmanına ulaşmadan önce öyle ayrıştırılmalıdır.
  • Bir statement timeout belirleyin; bu hem zamana dayalı kör enjeksiyonu hem de kontrolden çıkan sorguları sınırlar.

Kodunuzda zaten var olanları bulmak

İşe yukarıda listelenen kaçış kapılarını arayarak başlayın, ardından dize biçimlendirmenin yakınındaki SQL anahtar sözcüklerine bakın: template literal'ler, f-string'ler, Sprintf veya + birleştirmesi içindeki SELECT ya da WHERE. Her eşleşme ya doğru bağlanmıştır, ya bir izin listesinden oluşturulmuştur ya da bir hatadır. Dahili görünen kod yollarını atlamayın: CSV içe aktarıcılar, cron işleri ve yönetici panelleri çoğu zaman aslen bir kullanıcıdan gelmiş, yalnızca bir adım uzaklaşmış girdiler alır. Kendi veritabanınızdan geri okunan değerler, daha önce güvenle saklanıp sonradan bir sorguya yapıştırılan bir yük taşıyabilir; buna ikinci derece (second-order) enjeksiyon denir. CodeAuditAgent bu izlemeyi herkese açık GitHub depoları ve yapıştırılmış kod için dosyalar arasında yapar ve CWE-89 bulgularını alıntılanmış sorgu, girdinin sorguya nasıl ulaştığı ve yamalanmış bir sürümle birlikte raporlar.

Akılda tutulacak kural kısadır: değerler parametrelere girer, tanımlayıcılar bir izin listesinden gelir ve istekten gelen hiçbir şey asla SQL metnine yapıştırılmaz.