Bir siteye dosya yükleme özelliği eklemek birkaç satırlık iş gibi görünür: bir form, bir move_uploaded_file() çağrısı ve bitti. Oysa yükleme formu, bir PHP uygulamasına açılan en geniş kapılardan biridir. Kontrolsüz bir yükleme alanı, saldırganın sunucuya kendi PHP dosyasını bırakıp çalıştırmasına, yani bir web shell açmasına kadar gidebilir.

Web uygulama açıkları yazısında web shell saldırısının nasıl çalıştığını anlatmıştım. Bu yazıda işin savunma tarafına odaklanıyorum: her dosya yüklemesinde uyguladığım sekiz kontrolü, kopyalayıp kullanabileceğiniz örnek kodlarla sırayla anlatacağım.

1. Önce yüklemenin kendisini doğrulayın

PHP, yüklenen dosyayı geçici bir klasöre koyar ve sonucu $_FILES dizisine yazar. İlk iş, yüklemenin gerçekten başarılı olduğunu ve dosyanın bu istekle geldiğini doğrulamaktır:

  • error alanı UPLOAD_ERR_OK olmalı. Diğer kodlar boyut sınırı, yarım yükleme ya da dosya seçilmemesi gibi durumları anlatır.
  • is_uploaded_file() dosyanın gerçekten HTTP ile yüklendiğini doğrular; başka bir yoldan gelen bir dosya adıyla oynanmasını engeller.
  • Boyutu kendi sınırınızla karşılaştırın. php.ini sınırı yalnız üst güvenlik çizgisidir; uygulamanın ihtiyacı çoğu zaman çok daha düşüktür.

upload_max_filesize ve post_max_size birlikte çalışır. İstek gövdesi post_max_size değerini aşarsa PHP $_POST ve $_FILES dizilerini tamamen boş bırakır; kullanıcıya anlamlı bir hata göstermek için bu durumu ayrıca kontrol edin.

2. Uzantıya değil, içeriğe bakın

Tarayıcının gönderdiği type alanı ($_FILES['dosya']['type']) ve dosya adındaki uzantı tamamen istemcinin kontrolündedir. Bir saldırgan kabuk.php dosyasını image/jpeg türüyle göndermekte hiç zorlanmaz.

Bunun yerine dosyanın ilk baytlarına bakarak gerçek türünü bulan finfo sınıfını kullanın ve sonucu bir izin listesiyle karşılaştırın. Yasaklı uzantılar listesi tutmak (kara liste) her zaman eksik kalır: .phtml, .phar, .php7 gibi seçenekleri unutmak çok kolaydır.

const IZINLI = [
    'image/jpeg'      => 'jpg',
    'image/png'       => 'png',
    'image/webp'      => 'webp',
    'application/pdf' => 'pdf',
];

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['dosya']['tmp_name']);
if (!isset(IZINLI[$mime])) {
    throw new RuntimeException('Bu dosya türüne izin verilmiyor.');
}

3. Uzantıyı ve dosya adını siz belirleyin

İçerik türünü doğruladıktan sonra kullanıcının verdiği adı tamamen bir kenara bırakın. Uzantıyı izin listesinden, adı ise rastgele üretin. Bu tek adım birden fazla sorunu birden çözer:

  • resim.php.jpg gibi çift uzantılı adlar ve bazı Apache yapılandırmalarında bu dosyaların PHP olarak çalışması riski ortadan kalkar.
  • ../../index.php gibi klasör atlatma denemeleri anlamını yitirir.
  • Aynı adla yüklenen iki dosya birbirinin üzerine yazılmaz.
  • Dosya adı tahmin edilemez; özel belgelerin adresi deneme yanılmayla bulunamaz.
$ad    = bin2hex(random_bytes(16)) . '.' . IZINLI[$mime]; // ör. 9f1c...e2.webp
$hedef = $klasor . '/' . $ad;

if (!move_uploaded_file($_FILES['dosya']['tmp_name'], $hedef)) {
    throw new RuntimeException('Dosya kaydedilemedi.');
}
chmod($hedef, 0644); // çalıştırma izni yok

Özgün adı göstermek istiyorsanız (ör. indirme sırasında) veritabanına ayrı bir alan olarak, temizlenmiş biçimde kaydedin; diskteki adla asla karıştırmayın.

4. Yükleme klasöründe betik çalışmasın

Bütün kontrollere rağmen bir gün bir PHP dosyası yükleme klasörüne düşerse, son savunma hattı o klasörde hiçbir betiğin çalışmamasıdır. En iyisi dosyaları web kökünün dışında saklamaktır. Paylaşımlı hosting gibi bunun zor olduğu ortamlarda yükleme klasörüne bir .htaccess koyun:

# Bu klasörde hiçbir betik çalışmasın
<FilesMatch "\.(php\d?|phtml|phar|pl|py|cgi|sh)$">
    Require all denied
</FilesMatch>
RemoveHandler .php .phtml .phar
RemoveType .php .phtml .phar
Options -ExecCGI -Indexes

<IfModule mod_headers.c>
    Header set X-Content-Type-Options "nosniff"
</IfModule>

Sık görülen php_flag engine off satırı yalnız PHP Apache modülü olarak çalışıyorsa etkilidir. PHP-FPM kullanan sunucularda (bugün çoğu hosting böyle) hiçbir işe yaramaz, hatta 500 hatasına yol açabilir. Yukarıdaki FilesMatch kuralı her iki durumda da çalışır.

5. Görselleri yeniden kaydedin

Geçerli bir JPEG dosyasının içine, meta veri alanlarına ya da dosyanın sonuna PHP veya JavaScript kodu gizlemek mümkündür. Görseli GD ile açıp yeniden kaydetmek bu fazlalıkları temizler. Bonus olarak EXIF verisi de silinir; böylece telefonla çekilmiş fotoğraflardaki GPS konumu gibi kişisel bilgiler yanlışlıkla yayımlanmaz.

Görseli açmadan önce boyutlarını getimagesize() ile kontrol edin. Bu fonksiyon yalnız dosya başlığını okur; birkaç kilobaytlık bir dosyanın 30000 x 30000 piksel olduğunu iddia edip açılırken gigabaytlarca bellek tüketmesini (sıkıştırma bombası) böylece baştan engellersiniz.

function gorseliTemizle(string $yol): string
{
    [$en, $boy] = getimagesize($yol) ?: [0, 0];
    if ($en < 1 || $boy < 1 || $en * $boy > 40_000_000) {
        throw new RuntimeException('Görsel boyutları geçersiz.');
    }

    $img = imagecreatefromstring((string)file_get_contents($yol));
    if ($img === false) {
        throw new RuntimeException('Görsel okunamadı.');
    }
    imagepalettetotruecolor($img);
    imagesavealpha($img, true);

    $yeni = preg_replace('/\.\w+$/', '.webp', $yol);
    imagewebp($img, $yeni, 82); // temiz, meta verisiz bir kopya
    imagedestroy($img);

    if ($yeni !== $yol) {
        unlink($yol);
    }
    return $yeni;
}

6. Dosyaları doğru başlıklarla sunun

Dosyanın nasıl sunulduğu da en az nasıl kaydedildiği kadar önemlidir. Özellikle kullanıcıya özel belgeleri (fatura, özgeçmiş, sözleşme) doğrudan klasör adresinden değil, yetki kontrolü yapan küçük bir PHP betiğiyle sunun:

$ad = basename((string)($_GET['dosya'] ?? ''));
if (!preg_match('/^[a-f0-9]{32}\.(pdf|jpg|png|webp)$/', $ad)) {
    http_response_code(404);
    exit;
}
// Burada oturum ve yetki kontrolü yapılır: bu dosyayı bu kullanıcı görebilir mi?

$yol = __DIR__ . '/../depo/' . $ad; // web kökünün dışı
if (!is_file($yol)) {
    http_response_code(404);
    exit;
}

header('Content-Type: ' . (new finfo(FILEINFO_MIME_TYPE))->file($yol));
header('Content-Length: ' . filesize($yol));
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: attachment; filename="belge.' . pathinfo($ad, PATHINFO_EXTENSION) . '"');
readfile($yol);

Buradaki üç başlığın her birinin bir görevi var: Content-Type dosyanın türünü sunucu tarafında belirler, nosniff tarayıcının türü kendi tahmin etmesini engeller, attachment ise dosyanın sitenizin adresinde açılmak yerine indirilmesini sağlar.

SVG bir görsel gibi görünse de aslında XML'dir ve içinde <script> barındırabilir. Kullanıcıdan SVG kabul ediyorsanız ya bir temizleyiciden geçirin ya da yalnız attachment olarak sunun. En güvenlisi, ziyaretçi yüklemelerinde SVG'yi izin listesine hiç almamaktır.

7. Formu ve isteği koruyun

Dosyanın kendisi kadar, yüklemeyi kimin ve ne sıklıkla yaptığı da önemlidir:

  • CSRF jetonu: Yükleme formu da her form gibi bir jetonla korunmalı; aksi hâlde başka bir site, oturum açmış kullanıcınız adına dosya yükletebilir.
  • Yetki: Yükleme uç noktası yalnız izinli kullanıcılara açık olmalı; arayüzde düğmeyi gizlemek yetmez.
  • Hız sınırı ve kota: Aynı IP ya da hesaptan dakikada kaç yükleme yapılabileceğini ve toplam disk kotasını sınırlayın. Aksi hâlde diskinizi doldurmak bir döngü yazmak kadar kolaydır.
<form action="/yukle" method="post" enctype="multipart/form-data">
  <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrfJetonu) ?>">
  <label for="dosya">Belge seçin (PDF, JPG, PNG, WebP; en fazla 5 MB)</label>
  <input id="dosya" name="dosya" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" required>
  <button type="submit">Yükle</button>
</form>

accept özniteliği yalnız kullanıcıya kolaylık sağlar; dosya seçme penceresini süzer ama güvenlik sağlamaz. Asıl kontrol her zaman sunucuda yapılır.

8. Hepsini bir araya getirelim

Yukarıdaki adımları tek bir fonksiyonda topladığımızda ortaya kısa ama sağlam bir yükleyici çıkıyor. Sunucuda klasör korumasıyla birlikte kullanın:

<?php
declare(strict_types=1);

const IZINLI = [
    'image/jpeg'      => 'jpg',
    'image/png'       => 'png',
    'image/webp'      => 'webp',
    'application/pdf' => 'pdf',
];
const AZAMI_BOYUT = 5 * 1024 * 1024; // 5 MB

function dosyaYukle(array $dosya, string $klasor): string
{
    if (($dosya['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        throw new RuntimeException('Yükleme başarısız oldu.');
    }
    if (!is_uploaded_file($dosya['tmp_name'])) {
        throw new RuntimeException('Geçersiz yükleme.');
    }
    if ($dosya['size'] <= 0 || $dosya['size'] > AZAMI_BOYUT) {
        throw new RuntimeException('Dosya en fazla 5 MB olabilir.');
    }

    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($dosya['tmp_name']);
    if (!isset(IZINLI[$mime])) {
        throw new RuntimeException('Bu dosya türüne izin verilmiyor.');
    }

    $hedef = rtrim($klasor, '/') . '/' . bin2hex(random_bytes(16)) . '.' . IZINLI[$mime];
    if (!move_uploaded_file($dosya['tmp_name'], $hedef)) {
        throw new RuntimeException('Dosya kaydedilemedi.');
    }
    chmod($hedef, 0644);

    // Görseller temiz bir WebP kopyasına dönüştürülür (5. adımdaki fonksiyon)
    if (str_starts_with($mime, 'image/')) {
        $hedef = gorseliTemizle($hedef);
    }
    return basename($hedef);
}
<FilesMatch "\.(php\d?|phtml|phar|pl|py|cgi|sh)$">
    Require all denied
</FilesMatch>
RemoveHandler .php .phtml .phar
RemoveType .php .phtml .phar
Options -ExecCGI -Indexes

Kısa kontrol listesi

  • Yükleme hatası, is_uploaded_file() ve boyut kontrol edildi mi?
  • Tür finfo ile içerikten bulunup izin listesiyle karşılaştırıldı mı?
  • Uzantı ve dosya adı sunucuda mı üretiliyor?
  • Yükleme klasöründe betik çalışması kapalı mı (ya da dosyalar web kökünün dışında mı)?
  • Görseller yeniden kaydedilip meta verilerden arındırılıyor mu?
  • Dosyalar doğru Content-Type, nosniff ve gerekiyorsa attachment ile mi sunuluyor?
  • Form CSRF jetonuyla, uç nokta yetki ve hız sınırıyla korunuyor mu?

Bu sitenin medya yükleyicisi de aynı mantıkla çalışıyor: türü içerikten doğruluyor, dosyaya kendi adını veriyor ve görselleri yeniden işleyip WebP olarak kaydediyor. Bu listedeki maddelerin hiçbiri tek başına yeterli değil; ama birlikte uygulandığında yükleme formu sitenizin en zayıf halkası olmaktan çıkıyor.