Parametreli sorgu
- Parametreli sorguyu üç dilde sürücü düzeyinde kur; ORM kaçış kapılarını, yapısal parçaları, NoSQL enjeksiyonunu ve Bandit ile tespiti öğren.
- Parametreli sorgu veriyi sorgu şablonundan ayrı bir kanalda taşır ve bu karışmayı engeller.
- Bu derste iade arama sorgusunu string birleştirmeden parametreli hale getireceksin.
Puan bütün etkinlikleri cevaplayınca hesaba yazılır. Bu dersi tamamlayınca +50 XP alacaksın. Giriş yapmadan ilerlersen puan yalnız bu tarayıcıda durur.
Girdiyi doğruladın, çıktıyı kodladın. Sorgu tarafında farklı bir tehlike var. Yaygın hata, kullanıcı verisini SQL metniyle aynı string içinde birleştirmektir.
AltayKargo iade servisi takip numarasına göre kayıt ararken sorguyu metin birleştirmeyle kurabilir. Bu durumda saldırgan takip numarası alanına bir tırnak işareti ve ardından her zaman doğru olan bir koşul yazar. Sorgu bu koşulla tüm tabloyu döner, çünkü veritabanı hangi parçanın veri, hangi parçanın komut olduğunu ayırt edemez. Bu saldırıya SQL enjeksiyonu denir.
Kurulumu basit, veri kaybı büyük ve tespiti bazen aylar sonra mümkün olduğu için uzun yıllar en kritik risk listelerinin başında durdu. Bugün de CWE-89 kimliğiyle 2025 CWE Top 25 listesinde ikinci sıradadır. OWASP Top 10:2025'te A05 Enjeksiyon kategorisinde 14 binden fazla CVE kaydıyla yer alır.
Hazırla, bağla, çalıştır
// Önce: kullanıcı verisi sorgu metnine doğrudan birleştiriliyor
const sorgu = `SELECT * FROM iadeler WHERE takip_no = '${takipNo}'`;
const sonuc = await db.raw(sorgu);
-- Veritabanının gördüğü metin, takipNo = AK482913
SELECT * FROM iadeler WHERE takip_no = 'AK482913'
-- takipNo = x' OR '1'='1
SELECT * FROM iadeler WHERE takip_no = 'x' OR '1'='1'Son satırı dikkatle oku. Kullanıcı değerindeki ilk tırnak, geliştiricinin açtığı veri alanını kapatır. Ardından gelen OR sorgunun parçası olur.
'1'='1' karşılaştırması her satırda doğru döndüğü için WHERE koşulu bütün kayıtları seçer. Veritabanı sorguyu iki aşamada işler. Önce metni ayrıştırır ve bir çalışma planı kurar, sonra planı çalıştırır.
String birleştirmede kullanıcı değeri ayrıştırmadan önce metne girer. Ayrıştırıcı için hangi karakterin kimden geldiği artık belli değildir. Tırnakları elle kaçışlamak bu yüzden kalıcı çözüm olmaz. Karakter kümesi farkları, veritabanına özgü kaçış kuralları ve tırnaksız sayısal alanlar hep bir boşluk bırakır.
Parametreli sorgu bu karışmayı kökten çözer. Sürücü işi üç adıma böler: hazırla, bağla, çalıştır. Önce yer tutucu taşıyan sorgu şablonu veritabanına gider ve ayrıştırılır. Sonra değerler ayrı bir parametre listesiyle bağlanır.
En son sorgu çalışır. Veri hiç metne girmediği için tırnak kaçırmaya da gerek kalmaz. Yer tutucunun yazımı sürücüye göre değişir: node-postgres $1, psycopg %s, JDBC ve Knex ? kullanır.
Python'da SQLAlchemy'nin text() fonksiyonu gibi bazı kütüphaneler :takip_no biçiminde adlı parametre kabul eder. ORM ve sorgu oluşturucu kütüphaneler (Prisma, Sequelize, Knex gibi) bunu varsayılan olarak yapar. Tehlike genelde ham SQL fonksiyonlarında elle string birleştirilince ortaya çıkar.
// Sonra, Knex: ? yer tutucusu, değer ayrı bir dizide
const sonuc = await db.raw("SELECT * FROM iadeler WHERE takip_no = ?", [takipNo]);
// Node.js, node-postgres: $1 yer tutucusu
const { rows } = await client.query(
"SELECT * FROM iadeler WHERE takip_no = $1", [takipNo]);
# Python, psycopg 3
cur.execute(f"SELECT * FROM iadeler WHERE takip_no = '{takip_no}'") # YANLIŞ
cur.execute("SELECT * FROM iadeler WHERE takip_no = %s", (takip_no,)) # DOĞRU
// Java, JDBC
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM iadeler WHERE takip_no = ?");
ps.setString(1, takipNo);
ResultSet rs = ps.executeQuery();Dört yazım aynı ilkeyi uygular. Knex ve Node tarafında değer dizinin içindedir, ? ya da $1 ise onun sorgudaki yeridir. Python'da ilk satır f-string ile değeri metne gömer ve psycopg bu metni olduğu gibi çalıştırır.
İkinci satırdaki %s bir yer tutucudur, Python'un % biçimlendirmesi değildir. Değer ayrı bir demette (tuple) gider. Tek elemanlı demette sondaki virgülü unutma, çünkü (takip_no) yazımı demet değil düz metindir. Java'da prepareStatement şablonu hazırlar, setString(1, takipNo) birinci soru işaretine değeri bağlar.
Sıra numarası 1'den başlar. Takip numarası alanına bir tırnak ve her zaman doğru bir koşul yazılsa bile sürücü bunu tek bir metin değeri olarak arar. Eşleşen kayıt bulunamaz.
Derinleş:Sunucu tarafı bağlama ve taklit edilen hazırlık
Parametreli sorgunun her sürücüde aynı yoldan çalıştığını sanma. PostgreSQL'in genişletilmiş sorgu protokolü (extended query) işi üç ayrı mesaja böler: Parse, Bind ve Execute. Parse mesajı yer tutuculu şablonu taşır, Bind değerleri ayrı alanlarda gönderir.
Değer hiçbir aşamada şablonla aynı metne girmez. psycopg 3 bu sunucu tarafı bağlamayı varsayılan olarak kullanır. Eski psycopg2 ise değerleri istemcide tırnaklayıp sorguya yerleştiriyordu.
Sunucu tarafı bağlamanın bir bedeli de var. SET ya da NOTIFY gibi bazı komutlar parametre kabul etmez. Parametre verilen tek bir execute çağrısında noktalı virgülle ayrılmış birden çok ifade de çalışmaz. PHP'nin PDO_MYSQL sürücüsü ise hazırlanmış ifadeleri varsayılan olarak taklit eder (emulated prepares).
Değerler istemcide kaçışlanıp metne yerleştirilir. Bu taklit, bağlantının karakter kümesi doğru ayarlandığında güvenlidir. Geçmişte yanlış ayarlanan çok baytlı karakter kümeleri kaçış hatalarına yol açtı. Sürücünü tanı, belgesinde bağlamanın nerede yapıldığına bak.
Etkinlik 1 / 7
Parametreli sorguya takipNo olarak bir tırnak ve ardından her zaman doğru bir koşul gönderilirse ne olur?
Önce kodu oku, sonra çıktıyı seç.
const sonuc = await db.raw("SELECT * FROM iadeler WHERE takip_no = ?", [takipNo]);Etkinlik 2 / 7
Her sürücüyü ya da kütüphaneyi kullandığı yer tutucu yazımıyla eşle.
Yapısal parçalar: sütun adı, sıralama ve liste
Yer tutucu yalnız bir değerin yerine geçer. Tablo adı, sütun adı, ORDER BY yönü ya da bir SQL anahtar kelimesi parametre olamaz. node-postgres belgesi bunu açıkça yazar: PostgreSQL tanımlayıcılar (identifier) için parametre desteklemez. Bu parçalar hâlâ metne eklenir ve izin listesiyle kontrol edilir.
En güvenli yol eşlemedir. İstemci sütun adını değil, bir anahtar gönderir: tarih ya da tutar.
Sunucu bu anahtarı kendi sabit sözlüğünde arar ve gerçek sütun adını oradan alır. Sözlükte olmayan anahtar varsayılana döner. Böylece kullanıcı girdisi sorgu metnine hiç ulaşmaz, metne yalnız geliştiricinin yazdığı sabit değerler girer.
const SIRALAMA = new Map([["tarih", "olusturma_tarihi"], ["tutar", "tutar"]]);
const YON = new Map([["artan", "ASC"], ["azalan", "DESC"]]);
const sutun = SIRALAMA.get(req.query.sirala) ?? "olusturma_tarihi";
const yon = YON.get(req.query.yon) ?? "DESC";
const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 20, 1), 100);
const { rows } = await client.query(
`SELECT * FROM iadeler WHERE musteri_id = $1 ORDER BY ${sutun} ${yon} LIMIT $2`,
[musteriId, limit]);
// IN listesi: değerler yine parametre, yalnız yer tutucu sayısı dinamik
const idler = req.body.idler.filter(Number.isInteger).slice(0, 50);
const yerler = idler.map((_, i) => `$${i + 1}`).join(", ");
await client.query(`SELECT * FROM iadeler WHERE id IN (${yerler})`, idler);Kodu satır satır oku. SIRALAMA ve YON sözlükleri Map olarak tanımlı. Düz nesne yerine Map kullanmanın nedeni, __proto__ ya da constructor gibi anahtarların nesnenin kendi özelliği gibi davranmasını önlemektir.
İstemci sirala=tarih gönderirse sorguya olusturma_tarihi girer, tanımsız bir anahtar varsayılan sütuna düşer. LIMIT değeri tamsayıya çevrilir, bir ile yüz arasında tutulur. Üst sınır olmasa tek bir istek milyonlarca satır çekebilir.
IN listesinde değerler yine parametredir. Kod yalnız yer tutucu sayısını dinamik üretir: $1, $2, $3. Liste de elli elemanla sınırlanır. PostgreSQL'de aynı işi = ANY($1) yazımıyla, diziyi tek parametre olarak bağlayarak da yapabilirsin.
Etkinlik 3 / 7
Aşağıdaki iki sorgudan hangi satır hâlâ enjeksiyona açık?
ORM ve sorgu oluşturucuda kaçış kapıları
ORM kullanan bir ekip çoğu zaman SQL enjeksiyonunu kapanmış sayar. Yapılandırılmış fonksiyonlar için bu doğrudur, ama her ORM'in ham SQL'e açılan bir kapısı vardır. Prisma'da iki ham sorgu fonksiyonu yan yana durur.
$queryRaw etiketli şablonla (tagged template) çağrılır ve şablondaki her değer otomatik olarak parametreye dönüşür. $queryRawUnsafe ise düz bir string alır. String'i birleştirmeyle kurarsan açık doğrudan geri gelir. Prisma belgesi de adında Unsafe geçen fonksiyonların kodu SQL enjeksiyonuna açık bırakma riskinin yüksek olduğunu yazar.
Sequelize'da literal, Knex'te raw ve whereRaw benzer kapılardır. Knex'in raw fonksiyonu ? yer tutucusu ve değer dizisiyle güvenlidir, birleştirmeyle güvensizdir.
// Güvenli: etiketli şablon, değer parametreye dönüşür
const a = await prisma.$queryRaw`SELECT * FROM iadeler WHERE takip_no = ${takipNo}`;
// Açık: string birleştirme
const b = await prisma.$queryRawUnsafe(`SELECT * FROM iadeler WHERE takip_no = '${takipNo}'`);
// Unsafe gerekiyorsa değeri yine ayrı argüman olarak ver
const c = await prisma.$queryRawUnsafe("SELECT * FROM iadeler WHERE takip_no = $1", takipNo);İlk ve ikinci satır göze neredeyse aynı görünür, ikisinde de ${takipNo} yazar. Fark, string'in kime verildiğidir. Etiketli şablonda Prisma şablonu parçalara ayrılmış olarak alır, her ${} yerini bir yer tutucuya çevirir.
Düz string'te JavaScript ${} ifadesini önce kendisi çözer. Prisma'ya ulaşan artık birleştirilmiş, tek parça bir metindir. Kod incelemesinde bu kapıları bulmanın en hızlı yolu bir ad listesidir.
Depoda $queryRawUnsafe, $executeRawUnsafe, Prisma.raw, literal, whereRaw ve orderByRaw adlarını ara. Python'da execute çağrısına giden f-string, Java'da createStatement ile kurulan birleştirilmiş sorgu da aynı listeye girer. Her eşleşmede şunu sor: bu string'in bir parçası dışarıdan geliyor mu?
Etkinlik 4 / 7
Doğru mu, yanlış mı?
Bir ORM'in .where({alan: deger}) gibi yapılandırılmış sorgu fonksiyonlarını kullanmak, arka planda parametreli sorgu ürettiği için güvenlidir.
Etkinlik 5 / 7
Üç Prisma çağrısından hangi satır SQL enjeksiyonuna açık?
NoSQL ve ikinci derece enjeksiyon
Enjeksiyon yalnız SQL'e özgü değildir. MongoDB gibi belge veritabanları sorguyu metin yerine bir nesneyle kurar. Nesne kullanmak birleştirme sorununu ortadan kaldırır ama yeni bir kapı açar: operatör enjeksiyonu. Aşağıdaki kod takip numarasını gövdeden alıp doğrudan filtreye koyar.
İstemci metin yerine {"$ne": null} nesnesi gönderirse filtre, takip numarası boş olmayan ilk kaydı seçer. $ne operatörü eşit değil demektir. Veri yine komut olmuştur, yalnız bu sefer JSON biçiminde. $regex ve $where operatörleri daha da tehlikelidir.
İlki veriyi karakter karakter sızdırmaya, ikincisi sunucuda JavaScript ifadesi değerlendirmeye kapı açar. Savunma üç katmanlıdır. Alanın tipi metin olmak zorundadır, nesne gelirse istek reddedilir. $ ile başlayan anahtarlar kabul edilmez. En sağlamı katı bir şemadır, çünkü şema tipi ve alan listesini tek yerde kilitler.
// Açık: gövdedeki değer olduğu gibi filtreye giriyor
const iade = await iadeler.findOne({ takip_no: req.body.takipNo });
// Gövde: {"takipNo": {"$ne": null}} -> ilk kayıt döner
// Düzeltme: tip ve biçim denetimi, sahiplik koşulu
const { takipNo } = req.body;
if (typeof takipNo !== "string" || !/^AK\d{6}$/.test(takipNo))
return res.status(400).json({ hata: "gecersiz_takip_no" });
const iade = await iadeler.findOne({ takip_no: takipNo, musteri_id: musteriId });İkinci derece enjeksiyon (second-order injection) daha sinsidir. Değer ilk istekte güvenle, parametreyle kaydedilir. Sorun sonra çıkar. Başka bir işlem aynı değeri veritabanından okur, güvenilir sayar ve bir sorgu metnine birleştirir.
Örneğin müşteri adı parametreyle kaydedilir. Gece çalışan raporlama betiği bu adı okuyup bir sorguya yapıştırırsa, adın içindeki tırnak o gece çalışır.
Kural basittir. Verinin nereden geldiğine bakma, her sorguda parametre kullan. Veritabanından okunan değer de kullanıcı verisidir, çünkü onu ilk yazan kullanıcıdır. Kod incelemesinde yalnız istek işleyicilerine değil, zamanlanmış işlere ve yönetim betiklerine de bak.
Etkinlik 6 / 7
Bir NoSQL veritabanında sorgu filtresini kullanıcı girdisinden doğrudan bir operatör nesnesi olarak inşa etmek hangi ilkeyi çiğner?
Derinlemesine savunma ve tespit
Parametreli sorgu açığın kendisini kapatır. ABD siber güvenlik ajansı CISA'nın Ocak 2025 tarihli kötü pratikler rehberi de kullanıcı girdisini SQL sorgu metnine doğrudan katmayı kaçınılacak uygulamalar arasında sayar. Derinlemesine savunma ise bir sorgu gözden kaçarsa zararı sınırlar.
İlk katman en az yetkidir. Uygulamanın veritabanı rolü yalnız ihtiyaç duyduğu tablolarda, yalnız ihtiyaç duyduğu işlemleri yapabilmelidir. İade servisi kullanıcılar tablosunu okuyamıyorsa, kaçan bir enjeksiyon da okuyamaz.
İkinci katman hata yanıtıdır. Veritabanı hatasını olduğu gibi istemciye döndürmek saldırgana tablo ve sütun adlarını öğretir. Bu zayıflık CWE-209 kimliğini taşır. İstemciye genel bir hata ve bir olay kimliği dön, ayrıntıyı günlüğe yaz.
Üçüncü katman saklı yordamlardır. Yordamın içinde dinamik SQL kullanılıp parametreler metne birleştirilirse, dışarıdan parametreli çağrılsa bile açık içeride devam eder.
-- Açık: yordamın içinde değer metne birleştiriliyor EXECUTE 'SELECT * FROM iadeler WHERE takip_no = ''' || p_takip_no || ''''; -- Güvenli: değer USING ile parametre olarak bağlanıyor EXECUTE 'SELECT * FROM iadeler WHERE takip_no = $1' USING p_takip_no; -- En az yetki: uygulama rolü yalnız iade tablosunda okur ve ekler GRANT SELECT, INSERT ON iadeler TO iade_uygulama;
Tespitte statik analiz (SAST) kodu çalıştırmadan okur ve birleştirmeyle kurulan sorguları işaretler. Python için yaygın araç Bandit'tir. B608 kuralı string ile kurulan SQL ifadelerini arar.
Aşağıdaki çıktıyı satır satır oku. Issue satırı bulgunun özetidir. Severity önem derecesini, Confidence aracın ne kadar emin olduğunu verir.
Bandit, sorgu metni bir execute çağrısına gidiyorsa güven düzeyini Low'dan Medium'a yükseltir. CWE satırı bulguyu CWE-89'a eşler, Location dosya ve satırı gösterir. Düşük güvenli bulgu yanlış alarm olabilir, her birini elle doğrula. Çalışma zamanında ise veritabanı sözdizimi hatalarındaki ani artış ve tek kaynaktan gelen tırnaklı isteklerin yoğunlaşması iz verir.
$ bandit -r src/
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
Severity: Medium Confidence: Medium
CWE: CWE-89 (...)
Location: src/iade/rapor.py:42:15
41 def musteri_iadeleri(cur, ad):
42 cur.execute(f"SELECT * FROM iadeler WHERE musteri_adi = '{ad}'")Derinleş:Çeyrek asrı aşan bir açık
SQL enjeksiyonunun bilinen ilk kamuya açık tarifi 25 Aralık 1998'de bir güvenlik dergisinde yayımlandı. Yazar, web sayfalarının kullanıcı parametresini SQL Server sorgusuna eklediğini ve araya ikinci bir komut sıkıştırılabildiğini gösterdi. Sorunun kökü o gün de bugünkü gibiydi: kullanıcı verisi komut metninin parçası oluyordu. Aradan geçen yıllarda sorun küçülmedi.
OWASP Top 10 listesinde enjeksiyon 2010, 2013 ve 2017 sürümlerinde birinci sıradaydı. 2021'de üçüncü, 2025'te beşinci sıraya indi. Bu düşüş sorunun bittiği anlamına gelmez. Çerçeveler parametreli sorguyu varsayılan yaptı, listede başka riskler öne çıktı.
SQL enjeksiyonu 2025 CWE Top 25 listesinde hâlâ ikinci sıradadır. Uzun ömrünün nedeni tekniğin basitliğidir. Her yeni dil ve her yeni veritabanı, kullanıcı verisini komut metnine karıştırmanın yeni bir yolunu sunar. NoSQL operatör enjeksiyonu ve LLM tabanlı sorgu üreticileri bu ailenin yeni üyeleridir.
Etkinlik 7 / 7
Lab deposundaki src klasörünü Bandit ile alt klasörleriyle birlikte tara.
Okumayı istediğin sırayla sürdürebilirsin. Ders, bütün etkinlikleri cevapladığında tamamlanır.
- Etkinlik 1· Hazırla, bağla, çalıştır
- Etkinlik 2· Hazırla, bağla, çalıştır
- Etkinlik 3· Yapısal parçalar: sütun adı, sıralama ve liste
- Etkinlik 4· ORM ve sorgu oluşturucuda kaçış kapıları
- Etkinlik 5· ORM ve sorgu oluşturucuda kaçış kapıları
- Etkinlik 6· NoSQL ve ikinci derece enjeksiyon
- Etkinlik 7· Derinlemesine savunma ve tespit