2026-09-15altyapi4 dk

Dosya yükleme ucunu güvenli yazmak: tür, boyut, isim, depolama

Yükleme ucunda dört karar noktası: boyutu akışta sınırlamak, türü içerikten belirlemek, kullanıcı adını diske yazmamak ve dosyayı ayrı yerden doğru başlıklarla sunmak.

Dosya yükleme ucu çoğu projede beş dakikada yazılır ve yıllarca dokunulmaz. Sorunlar da oradan çıkar: istemcinin beyan ettiği MIME türüne güvenen kontrol, belleğe tamamı okunan dev bir istek, kullanıcının verdiği adla diske yazılan dosya, uygulamanın kendi alan adından text/html gibi yorumlanan bir "resim". Bu not tek bir uç için dört karar noktasını sırayla ele alıyor: boyut, tür, isim, depolama.

1. Boyut: sınır akışta konur, sonda değil

Gövdenin tamamını okuyup buffer.length ile karşılaştırmak geç kalmaktır. O noktada bellek zaten dolmuştur. Sınır üç katmanda olmalı:

  • Ters vekil: nginx için client_max_body_size ya da kullandığınız vekilin eşdeğeri.
  • Çözümleyici: multipart okunurken dosya başına bayt sınırı.
  • Depolama: doğrudan nesne deposuna yüklemede imzalı politika koşulu.

Node'da busboy ile çözümleyici katmanı:

import busboy from "busboy";

const MAX = 5 * 1024 * 1024; // 5 MB

export function yukle(req, res) {
  const bb = busboy({
    headers: req.headers,
    limits: { fileSize: MAX, files: 1, fields: 5 },
  });

  bb.on("file", async (_alan, akis, bilgi) => {
    const parcalar: Buffer[] = [];
    for await (const p of akis) parcalar.push(p);
    if (akis.truncated) return res.writeHead(413).end();

    await isle(Buffer.concat(parcalar), bilgi);
    res.writeHead(201).end();
  });

  req.pipe(bb);
}

fileSize aşılınca busboy akışı keser ve truncated bayrağını kaldırır. files: 1 ve fields: 5 de ayrıca önemli: sınır konmazsa tek istekte yüzlerce parça gönderilebilir. Birkaç megabaytın üstündeki dosyalarda belleğe toplamak yerine geçici dosyaya akıtın.

2. Tür: uzantıya ve Content-Type'a değil, içeriğe bakın

bilgi.mimeType istemcinin beyanıdır. foto.jpg adı da öyle. İkisi de karar girdisi olamaz. Asgari kontrol, dosyanın ilk baytlarındaki imzaya bakmaktır:

import { fileTypeFromBuffer } from "file-type";

const IZINLI = new Map([
  ["image/jpeg", "jpg"],
  ["image/png", "png"],
  ["application/pdf", "pdf"],
]);

const tur = await fileTypeFromBuffer(veri);
if (!tur || !IZINLI.has(tur.mime)) throw new Error("desteklenmeyen tür");

Kurallar:

  • İzin listesi kullanın, yasak listesi değil. .php, .phtml, .phar saymaya çalışmak bitmez.
  • SVG'yi resim saymayın. SVG bir XML belgesidir ve içinde <script> taşıyabilir.
  • İmza kontrolü poliglot dosyayı yakalamaz. Hem geçerli bir JPEG hem de başka bir biçim olarak yorumlanabilen dosyalar üretilebilir.

Resimlerde en sağlam yol yeniden kodlamak:

import sharp from "sharp";

const temiz = await sharp(veri, { limitInputPixels: 40_000_000 })
  .rotate()               // EXIF yönünü piksellere uygula
  .jpeg({ quality: 85 })  // yeniden kodla; meta veri varsayılan olarak atılır
  .toBuffer();

Yeniden kodlama iki işi birden görür. Dosyaya gizlenmiş yükü atar ve fotoğraftaki konum bilgisini siler; ikincisi kişisel veri açısından da önemlidir. limitInputPixels sıkıştırma bombasına karşı: birkaç kilobaytlık bir PNG, açıldığında devasa bir piksel dizisine dönüşebilir.

Zip kabul ediyorsanız risk büyür. Açarken toplam açılmış boyutu ve girdi sayısını sayın, ../ ya da mutlak yol içeren girdileri reddedin.

3. İsim: kullanıcının verdiği ad diske gitmez

bilgi.filename şunları içerebilir: ../../etc/passwd, sonuna gizli karakter eklenmiş bir uzantı, 400 karakterlik bir dize, Windows'ta ayrılmış CON adı. Temizlemeye çalışmak yerine hiç kullanmamak daha basit:

import { randomUUID } from "node:crypto";

const anahtar = `yuklemeler/${randomUUID()}.${IZINLI.get(tur.mime)}`;

Uzantı içerikten tespit edilen türden gelir, istemciden değil. Orijinal adı göstermeniz gerekiyorsa veritabanında ayrı bir metin sütununda tutun. İndirme sırasında Content-Disposition başlığına RFC 6266'ya uygun biçimde (filename*=UTF-8''...) kodlayarak yazın.

Rastgele anahtarın bir faydası daha var: tahmin edilebilir yol kalmaz. /yuklemeler/fatura-1043.pdf gören biri 1044'ü denemeye başlar.

4. Depolama ve sunum: ayrı yer, ayrı alan adı, doğru başlık

Yüklenen dosya uygulama sunucusunun web kökünde durmamalı. Sunucu belirli uzantıları çalıştıracak şekilde ayarlıysa, tür kontrolündeki tek bir açık uzaktan kod çalıştırmaya dönüşür. S3 uyumlu bir nesne deposu ya da web kökü dışındaki bir dizin kullanın.

Dosyayı geri sunarken:

Content-Type: image/jpeg                 # içerikten tespit edilip kaydedilen tür
X-Content-Type-Options: nosniff
Content-Disposition: attachment          # tarayıcıda açılması gerekmeyen türlerde
Content-Security-Policy: default-src 'none'; sandbox

nosniff olmadan tarayıcı içeriği koklayıp HTML olduğuna karar verebilir. Bir adım ötesi, kullanıcı içeriğini ana uygulamadan ayrı bir alan adından sunmak. GitHub'ın kullanıcı içeriğini githubusercontent.com üzerinden vermesinin sebebi bu: dosya bir şekilde script çalıştırsa bile ana alanın çerezlerine erişemez.

Doğrudan yükleme

Büyük dosyalarda gövdenin uygulama sunucusundan hiç geçmemesi istenir. O zaman doğrulama ikiye bölünür:

  1. Sunucu imzalı bir POST politikası üretir. Nesne anahtarını sunucu seçer, content-length-range koşulu boyutu depo tarafında sınırlar.
  2. Yükleme bitince istemci sunucuya haber verir. Sunucu nesneyi okur, türü içerikten doğrular, geçmeyen dosyayı siler. O ana kadar dosya karantina/ önekinde durur ve dışarıya sunulmaz.

İmzalı PUT adresinde, içerik uzunluğu imzaya dahil edilmedikçe boyut sınırlanmaz. Bu fark sık atlanır.

Kontrol listesi

  • Vekil, çözümleyici ve depoda boyut sınırı var.
  • İstek başına dosya ve alan sayısı sınırlı.
  • Tür içerikten ve izin listesiyle belirleniyor.
  • Resimler yeniden kodlanıyor, piksel sınırı tanımlı.
  • Nesne anahtarı sunucuda rastgele üretiliyor; orijinal ad yalnızca metin olarak saklanıyor.
  • Dosyalar web kökü dışında.
  • nosniff, doğru Content-Type, gerekiyorsa attachment gönderiliyor.
  • Kullanıcı içeriği ayrı alan adından sunuluyor.
  • Doğrudan yüklemede karantina ve sonradan doğrulama var.

Mevcut yükleme ucunuzu açın ve maddeleri tek tek işaretleyin. İşaretlenemeyen ilk madde, bir sonraki commit'in konusu.