Yıllardır PHP ile proje geliştiriyorum. Kendi kodumda da başkalarının kodunda da aynı güvenlik hatalarına tekrar tekrar rastladım.

Bu hataları önlemek çoğu zaman zor değil. Çoğu, kullanıcıdan gelen veriye fazla güvenmekten kaynaklanıyor. Bu yazıda en sık gördüğüm açıkları, hatalı ve düzeltilmiş kod örnekleriyle anlatacağım.

Genel bir çerçeve isterseniz OWASP Top 10 listesine de göz atmanızı öneririm.

1. SQL Enjeksiyonu

Hâlâ en yaygın ve en tehlikeli açık bu. Kullanıcı girdisi doğrudan SQL sorgusunun içine eklendiğinde ortaya çıkıyor.

Eski bir projede şuna benzer bir kodla karşılaşmıştım:

<?php
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = $id";
$result = $pdo->query($sql);

Biri adres çubuğuna ?id=1 OR 1=1 yazdığında tüm kullanıcılar listeleniyor. Daha kötü niyetli girdilerle veri çalınabilir, hatta tablolar silinebilir.

Çözüm: Hazırlanmış ifadeler

Benim kuralım basit: Sorguya hiçbir zaman string birleştirmeyle veri eklemem. Bunun yerine PDO hazırlanmış ifadelerini kullanırım.

<?php
$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $_GET['id']]);
$user = $stmt->fetch();

Dikkat edilmesi gereken bir nokta var: Tablo ve sütun adları parametre olarak bağlanamaz. Sıralama gibi durumlarda izin verilen değerlerden oluşan bir liste kullanıyorum:

<?php
$izinli = ['name', 'created_at', 'email'];
$sirala = in_array($_GET['sort'] ?? '', $izinli, true)
    ? $_GET['sort']
    : 'created_at';

$stmt = $pdo->query("SELECT * FROM users ORDER BY $sirala");

2. XSS (Siteler Arası Betik Çalıştırma)

XSS, kullanıcıdan gelen verinin sayfaya kaçışlanmadan basılmasıyla oluşur. Saldırgan, sayfaya kendi JavaScript kodunu yerleştirebilir.

<?php
echo "Merhaba " . $_GET['ad'];

Burada ad parametresine bir <script> etiketi gönderilirse tarayıcı onu çalıştırır. Oturum çerezleri çalınabilir, sayfa içeriği değiştirilebilir.

Çözüm: Çıktıyı her zaman kaçışlamak

Projelerimde hep küçük bir yardımcı fonksiyon tanımlarım:

<?php
function e(?string $deger): string
{
    return htmlspecialchars($deger ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

echo 'Merhaba ' . e($_GET['ad'] ?? '');

ENT_QUOTES bayrağı önemli. PHP 8.1 öncesinde varsayılan ayar tek tırnağı kaçışlamadığı için bu bayrak olmadan tek tırnaklı HTML nitelikleri açık kalabilir. Ayrıntılar için htmlspecialchars belgesine bakabilirsiniz.

Veriyi JavaScript içine aktarıyorsam htmlspecialchars yerine json_encode kullanırım:

<script>
const kullanici = <?= json_encode($kullanici, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>

Ek bir savunma katmanı olarak Content-Security-Policy başlığı da tanımlamanızı öneririm. Tek başına yeterli değildir ama bir hata gözden kaçtığında hasarı azaltır.

3. CSRF (Siteler Arası İstek Sahteciliği)

CSRF'de saldırgan, oturumu açık bir kullanıcının tarayıcısına onun haberi olmadan istek göndertir. Örneğin kötü niyetli bir sayfa, sizin sitenize gizlice parola değiştirme formu gönderebilir.

Tarayıcı çerezleri otomatik eklediği için sunucu bu isteği gerçek kullanıcıdan geliyor sanar.

Çözüm: Token doğrulaması

Her oturum için rastgele bir token üretip formlara gizli alan olarak ekliyorum:

<?php
session_start();

if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
?>
<form method="post" action="/parola-degistir">
    <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
    <input type="password" name="yeni_parola">
    <button type="submit">Kaydet</button>
</form>

Formu işlerken de karşılaştırmayı hash_equals ile yapıyorum:

<?php
$gelen = $_POST['csrf'] ?? '';

if (!hash_equals($_SESSION['csrf'] ?? '', $gelen)) {
    http_response_code(403);
    exit('Geçersiz istek.');
}

hash_equals, zamanlama saldırılarına karşı sabit sürede karşılaştırma yapar. Ayrıca durum değiştiren işlemleri asla GET isteğiyle yapmıyorum.

4. Dosya Dahil Etme ve Dosya Yükleme Açıkları

Bu iki açık sıklıkla birlikte karşıma çıkıyor. İkisi de saldırganın sunucuda kod çalıştırmasına kadar gidebilir.

Yerel dosya dahil etme (LFI)

Basit sayfa yönlendirmelerinde şu kalıp çok yaygın:

<?php
include $_GET['sayfa'] . '.php';

../ gibi dizin atlama ifadeleriyle sunucudaki başka dosyalar okunabilir. Çözüm yine izin listesi:

<?php
$sayfalar = [
    'anasayfa' => 'pages/anasayfa.php',
    'hakkimda' => 'pages/hakkimda.php',
    'iletisim' => 'pages/iletisim.php',
];

$anahtar = $_GET['sayfa'] ?? 'anasayfa';
require $sayfalar[$anahtar] ?? $sayfalar['anasayfa'];

allow_url_include ayarının kapalı olduğundan da emin olun. Varsayılan olarak kapalıdır, açmak için geçerli bir neden yoktur.

Güvensiz dosya yükleme

Bir profil fotoğrafı formunun .php dosyası kabul ettiğini düşünün. Dosya web kök dizinine kaydedilirse saldırgan onu tarayıcıdan çağırıp çalıştırabilir.

Benim uyguladığım adımlar şunlar:

  • $_FILES['dosya']['type'] değerine güvenmem, çünkü bu değeri istemci gönderir.
  • Gerçek MIME türünü finfo ile sunucuda kontrol ederim.
  • Dosyaya rastgele bir ad veririm, kullanıcının verdiği adı kullanmam.
  • Mümkünse dosyaları web kök dizininin dışında saklarım.
  • Boyut sınırı koyarım.
<?php
$izinli = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];

if (($_FILES['dosya']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
    exit('Yükleme başarısız.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$tur = $finfo->file($_FILES['dosya']['tmp_name']);

if (!isset($izinli[$tur]) || $_FILES['dosya']['size'] > 2 * 1024 * 1024) {
    exit('Geçersiz dosya.');
}

$yeniAd = bin2hex(random_bytes(16)) . '.' . $izinli[$tur];
move_uploaded_file($_FILES['dosya']['tmp_name'], '/var/uploads/' . $yeniAd);

5. Oturum, Parola ve Karşılaştırma Hataları

Giriş sistemleri, küçük hataların büyük sonuçlar doğurduğu yerlerdir.

Oturum sabitleme

Kullanıcı giriş yaptıktan sonra oturum kimliği değişmezse, saldırganın önceden bildiği bir kimlik geçerli kalabilir. Bu yüzden girişten hemen sonra session_regenerate_id çağırıyorum. Çerez ayarlarını da sıkılaştırıyorum:

<?php
session_set_cookie_params([
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

// Başarılı girişten sonra:
session_regenerate_id(true);

Parola saklama

md5 veya sha1 ile parola saklamak hâlâ karşıma çıkıyor. Bunlar parola için tasarlanmamış, çok hızlı algoritmalardır. Doğru yol password_hash kullanmak:

<?php
$hash = password_hash($parola, PASSWORD_DEFAULT);

if (password_verify($girilen, $hash)) {
    // Giriş başarılı
}

Gevşek karşılaştırma tuzağı

PHP'de == operatörü tür dönüşümü yapar. Örneğin '0e123' == '0e456' ifadesi true döner, çünkü iki değer de sayısal string olarak sıfır kabul edilir.

Hash, token veya kimlik karşılaştırırken bu ciddi bir açık olabilir. Ben güvenlikle ilgili her karşılaştırmada === ya da hash_equals kullanıyorum. in_array çağrılarında da üçüncü parametreyi true vermeyi alışkanlık hâline getirdim.

Sonuç

Bu açıkların hepsinin ortak noktası aynı: Kullanıcıdan gelen veriye güvenmek. Kendime koyduğum kısa kontrol listesi şöyle:

  1. SQL sorgularında yalnızca hazırlanmış ifadeler.
  2. Her çıktıda bağlama uygun kaçışlama.
  3. Durum değiştiren her formda CSRF token.
  4. Dosya adları ve yüklemelerde izin listesi.
  5. Güvenli oturum ayarları ve password_hash.
  6. Canlı ortamda display_errors kapalı.
Güvenlik sonradan eklenen bir özellik değil, kod yazarken edinilen bir alışkanlık.

Bu alışkanlıkları ilk satırdan uygularsanız, sonradan yama yapmakla uğraşmazsınız. Laravel veya Symfony gibi çatılar bu korumaların çoğunu hazır sunuyor. Yine de arka planda neler olduğunu bilmek, korumayı yanlışlıkla devre dışı bırakmanızı önler.