uurstudios
Tüm yazılar
GüvenlikWeb GeliştirmeOWASP

Web uygulamalarında en sık gördüğüm güvenlik hataları ve kodla düzeltmeleri

30 Eylül 2026 7 dk okumaUğur Can Elsun

Güvenlik açıklarının büyük kısmı egzotik saldırılardan değil, acele yazılmış sıradan koddan çıkıyor. Bir sayfada yetki kontrolü unutuluyor, bir sorguya kullanıcı girdisi doğrudan ekleniyor, bir API anahtarı depoya giriyor. Bunları bulmak için güvenlik uzmanı olmak gerekmiyor; neye bakacağınızı bilmek yetiyor.

OWASP Top 10, web uygulamalarındaki en yaygın risk kategorilerinin listesi ve en güncel sürümü 2025 sürümü. Listenin ilk sırasında yine Kırık Erişim Kontrolü (A01) var; yeni gelenler arasında Yazılım Tedarik Zinciri Hataları (A03) ve İstisnai Durumların Yanlış Ele Alınması (A10) bulunuyor. Enjeksiyon A05, Kimlik Doğrulama Hataları A07 sırasında. Bu yazıda listenin tamamını değil, küçük ve orta ölçekli projelerde en sık karşılaştığım beş konuyu kod örnekleriyle anlatıyorum.

1. Erişim kontrolü: giriş yapmış olmak yetkili olmak demek değil

En yaygın hata şu: sayfa "giriş yapılmış mı" diye bakıyor ama "bu kullanıcı bu kaydı görebilir mi" diye bakmıyor. Adres çubuğundaki /hastalar/1042 numarasını 1043 yapan kullanıcı başka birinin kaydını görüyor. Buna IDOR (güvensiz doğrudan nesne referansı) deniyor.

Hatalı hâli:

export async function getPatient(id: number) {
  const session = await requireSession(); // yalnızca giriş kontrolü
  return db.query.patients.findFirst({ where: eq(patients.id, id) });
}

Düzeltilmiş hâli, kullanıcının kapsamını sorgunun kendisine ekliyor:

export async function getPatient(id: number) {
  const s = await requireSession();
  const conds = [eq(patients.id, id)];
  if (s.role !== "yonetici") conds.push(eq(patients.branchId, s.branchId));
  const row = await db.query.patients.findFirst({ where: and(...conds) });
  if (!row) notFound(); // başkasının kaydı da "yok" olarak döner
  return row;
}

Portföyümdeki klinik yönetim paneli çalışmasında bu kuralı merkezi bir yerde tutuyorum: yönetici seçtiği şubeyi ya da tüm şubeleri görüyor, resepsiyon yalnızca kendi şubesini, hekim yalnızca kendi takvimini ve hastalarını. Kapsam her sayfada ayrı ayrı yazılmak yerine tek bir fonksiyondan geliyor; böylece yeni bir sayfa eklendiğinde kontrolü unutma ihtimali azalıyor. Uçtan uca testler de üç rolün her biriyle ayrı çalışıyor.

Aklınızda tutmanız gereken kural: yetki kontrolü arayüzde değil sunucuda yapılır. Düğmeyi gizlemek erişimi engellemez; isteği doğrudan gönderen biri için o düğme hiç yoktu.

2. Enjeksiyon: kullanıcı girdisi asla sorgu metni olmasın

SQL enjeksiyonu yıllardır biliniyor ve hâlâ listede. Sebebi hep aynı: sorgu metni string birleştirmeyle kuruluyor.

// Hatalı: q = "' OR 1=1 --" tüm tabloyu döndürür
db.prepare(`SELECT * FROM products WHERE name = '${q}'`).all();

// Doğru: parametreli sorgu
db.prepare(`SELECT * FROM products WHERE name = ?`).all(q);

ORM kullanmak çoğu durumda sizi korur, ama ham SQL yazdığınız yerler kalır. Kendi projelerimde en sık yakalandığım yer IN (...) listeleri ve sıralama sütunu. IN için soru işaretleri dizinin uzunluğu kadar üretiliyor. Sıralama sütunu parametre olarak bağlanamadığı için kullanıcıdan gelen değer bir izin listesinden geçiriliyor:

const ORDER = { "fiyat-artan": "p.price ASC", "fiyat-azalan": "p.price DESC" } as const;
const order = ORDER[input.sort as keyof typeof ORDER] ?? "p.id";

Enjeksiyon yalnızca SQL'de olmuyor. Outdoor e-ticaret mağazası çalışmamda tam metin arama için SQLite FTS5 kullanıyorum ve FTS5'in kendi sorgu dili var. Kullanıcının yazdığını doğrudan MATCH'e vermek yerine harf ve rakam dışındaki her karakter temizlenip her kelime tırnak içine alınıyor. Aynı mantık HTML için de geçerli: kullanıcı girdisini sayfaya basarken çerçevenin kaçış mekanizmasını kullanın, dangerouslySetInnerHTML gibi kapıları yalnızca temizlenmiş içerik için açın.

Bunların hepsinin temeli sunucu tarafı doğrulama. Web projelerimde tüm form ve Server Action girdilerini Zod şemalarından geçiriyorum; beklenmeyen alan, yanlış tip ya da aşırı uzun metin iş mantığına ulaşmıyor.

3. Gizli anahtarların koda girmesi

Bu, OWASP'ta tek başına bir başlık değil ama birkaç kategoriye birden dokunuyor ve pratikte çok sık görülüyor. Veritabanı parolası, ödeme sağlayıcısı anahtarı ya da oturum imza anahtarı bir kere Git geçmişine girdiyse, sonradan dosyadan silmek yetmez; geçmişte durmaya devam eder.

Kurallarım şunlar:

  • Anahtarlar ortam değişkenlerinde durur. Depoya yalnızca değerleri boş bir .env.example girer, gerçek .env dosyaları .gitignore'dadır.
  • Bir anahtar sızdıysa önce anahtar değiştirilir, geçmiş sonra temizlenir. Sıra önemli; geçmişi temizlemek sızan anahtarı geçersiz kılmaz.
  • Tarayıcıya giden kodda gizli anahtar olmaz. Next.js'te NEXT_PUBLIC_ önekli her değişken istemci paketine gömülür; bu önekle yalnızca herkese açık değerler tanımlanır.
  • Uygulama, zorunlu bir anahtar eksikse açılmayı reddetmelidir.

Son maddeyi kendi kodumdan bir örnekle anlatayım. Hukuk bürosu sitesi çalışmamda, demo herkes tarafından tek komutla açılabilsin diye oturum anahtarının bir geliştirme varsayılanı var:

const SECRET = process.env.SESSION_SECRET ?? "meridyen-dev-secret";

Portföy demosu için kabul edilebilir, gerçek bir projede değil. Canlıda bu değişkeni tanımlamayı unutursanız herkesin bildiği bir anahtarla imzalanmış oturumlar kabul edilir. Gerçek bir projede şöyle yazılmalı:

const SECRET = process.env.SESSION_SECRET;
if (!SECRET || SECRET.length < 32) throw new Error("SESSION_SECRET tanımlı değil");

Bu aynı zamanda A10'un konusu: beklenmedik bir durumda sistemin güvenli tarafta kapanması. Anahtar yoksa, yetki sorgusu hata verdiyse, doğrulama servisi yanıt vermiyorsa varsayılan cevap "izin ver" değil "reddet" olmalı.

4. Oturum yönetimi

Kendi oturum sisteminizi yazacaksanız ya da bir kütüphanenin doğru ayarlandığını kontrol edecekseniz şu maddelere bakın.

Parolalar düz metin ya da hızlı bir özet (MD5, SHA-256) olarak değil, bcrypt veya Argon2 gibi bilerek yavaş tasarlanmış bir algoritmayla saklanır. Klinik yönetim paneli ve depo yönetim sistemi çalışmalarımda bcrypt kullanıyorum.

Oturum çerezi httpOnly (JavaScript okuyamaz), secure (yalnızca HTTPS) ve sameSite ayarlı olur. Çerezin içeriği imzalanır ve süresi içinde taşınır. Hukuk bürosu sitesindeki doğrulama fonksiyonunun özü şu:

export function verifyToken(token?: string | null) {
  if (!token) return false;
  const i = token.lastIndexOf(".");
  if (i < 0) return false;
  const payload = token.slice(0, i), sig = token.slice(i + 1);
  const expected = createHmac("sha256", SECRET).update(payload).digest("hex");
  if (sig.length !== expected.length ||
      !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return false;
  const exp = Number(payload.split(".")[1]);
  return Number.isFinite(exp) && exp > Date.now();
}

İki ayrıntıya dikkat: imza === ile değil timingSafeEqual ile karşılaştırılıyor, böylece karşılaştırma süresi imzanın kaçıncı karakterde saptığını ele vermiyor. Süre de imzalı içeriğin parçası; kullanıcı çerezdeki süreyi uzatırsa imza tutmuyor.

Bu tür imzalı, durumsuz çerezin bir zayıflığı var: süresi dolmadan tek bir oturumu iptal edemezsiniz. "Tüm cihazlardan çıkış yap" ya da "bu kullanıcıyı hemen engelle" gerekiyorsa oturumları veritabanında tutup her istekte kontrol etmek gerekir. Girişten sonra oturum kimliğini yenilemek ve çıkışta sunucu tarafında da geçersiz kılmak bu yapının parçası.

5. Hız sınırlama

Giriş formu, parola sıfırlama, iletişim formu ve SMS gönderen her uç nokta sınırsız çağrılabiliyorsa, birisi onu parola denemesi, spam ya da fatura şişirmek için kullanır. Hukuk bürosu sitesindeki görüşme talebi formunda IP başına 10 dakikada en fazla 5 gönderime izin veren basit bir sınırlayıcı var:

const hits = new Map<string, number[]>();
export function rateLimited(key: string, limit = 5, windowMs = 10 * 60e3) {
  const now = Date.now();
  const arr = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
  if (arr.length >= limit) { hits.set(key, arr); return true; }
  arr.push(now); hits.set(key, arr); return false;
}

Yönetim paneli girişinde aynı fonksiyon IP başına 10 denemeyle çalışıyor. Formda bir de bal küpü (honeypot) alanı var: gerçek kullanıcının görmediği, botların doldurduğu gizli bir alan.

Bu sınırlayıcının sınırını da bilmek gerekiyor. Sayaç tek bir sürecin belleğinde duruyor; uygulama birden fazla sunucuda ya da sunucusuz fonksiyonlarda çalışıyorsa her kopyanın kendi sayacı olur ve sınır fiilen katlanır. Bu durumda sayacı Redis gibi ortak bir depoya taşımak ya da sınırlamayı ters vekil sunucu veya CDN katmanında yapmak gerekir. Giriş formunda IP'nin yanında hesap bazında da sınır koymak iyi fikir; saldırgan IP değiştirebilir ama hedef hesap aynı kalır.

Canlıya çıkmadan önce kısa kontrol listesi

  • Her sunucu fonksiyonu, kullanıcının o kayda erişim hakkını kendisi kontrol ediyor mu?
  • Tüm sorgular parametreli mi, sıralama gibi dinamik parçalar izin listesinden mi geçiyor?
  • Tüm girdiler sunucuda şema ile doğrulanıyor mu?
  • Depoda ve istemci paketinde gizli anahtar var mı? Zorunlu anahtar eksikse uygulama açılmayı reddediyor mu?
  • Parolalar bcrypt veya Argon2 ile mi saklanıyor, oturum çerezi httpOnly, secure ve sameSite mı?
  • Giriş, parola sıfırlama ve form uç noktalarında hız sınırı var mı, birden fazla sunucuda da çalışıyor mu?
  • Hata durumunda sistem "reddet" tarafında mı kalıyor?
  • Bağımlılıklar düzenli güncelleniyor ve bilinen açıklar için taranıyor mu?

İlgili yazılar

Bir projeniz mi var?

Fikrinizi anlatın, 24 saat içinde size özel bir teklif ve yol haritasıyla dönüş yapayım.

Teklif Al
WhatsApp