Dosya Yükleme Güvenliği Derinlemesine: Magic Byte Kontrolü, Görseli Yeniden Kodlamak, Zararlı Yazılım Taraması, Zip Bomb ve Blob'a Doğrudan SAS ile Yükleme
Yüklenen dosyanın adı da Content-Type'ı da istemcinin yazdığı metin; güvenlik, içeriği doğrulayıp dosyayı onaylanana kadar karantinada tutmaktan geçiyor. Magic byte ile izin listesini, polyglot'u etkisiz bırakan yeniden kodlamayı, EPUB'da zip bomb ve Zip Slip limitlerini, ClamAV ve Defender for Storage taramasını, tek blob'a yetkili kısa ömürlü SAS'ı ve imzalı indirme linkini Bookstore üzerinde uçtan uca kuruyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.15 runtime) ile derlenip denenmiştir; Azure.Storage.Blobs 12.30.1, SkiaSharp 4.156.1, xUnit v3 4.0.3, Azurite 3.37.0 ve ClamAV 1.5.4 kullanılmıştır. Microsoft Learn'deki "Upload files in ASP.NET Core" sayfası (3 Ekim 2026 güncellemesi) ve OWASP (Open Web Application Security Project; web güvenliği üzerine çalışan açık topluluk) File Upload Cheat Sheet 15 Kasım 2026'da kontrol edildi.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) yayınevleri kitap kapağını ve örnek bölümü kendileri yüklüyor. Bir yayınevi hesabından dune-cover.jpg adında, Content-Type: image/jpeg ile bir dosya geliyor. İlk byte'ları FF D8 FF, yani gerçekten bir JPEG (yaygın fotoğraf formatı); ama görüntü verisinin bittiği yerden sonra <script> içeren bir HTML parçası eklenmiş. Uzantı kontrolü, Content-Type kontrolü ve magic byte kontrolünden hangisi bu dosyayı durdurur?
Üçü de durdurmaz; dosya üç açıdan da geçerli bir JPEG. Bu yazıda yüklenen dosyanın içeriğini doğrulamayı, görseli yeniden kodlamayı, arşivleri ve zararlı yazılım taramasını ele alıyoruz; dosyayı da onay alana kadar karantinada tutup kullanıcıya kısa ömürlü, imzalı bir linkle veriyoruz. Temeller (IFormFile, dosya adına güvenmemek, rastgele isim, web root dışında saklamak) ASP.NET Core Web API serisinin dosya yükleme yazısında, body size limitleri bu serinin Kestrel yazısında; burada tekrar etmiyoruz.
Uzantı ve Content-Type Değil, İçerik: Magic Byte
Dosya adı da Content-Type header'ı da istemcinin yazdığı metin. Formatı dosyanın ilk byte'larından, yani magic byte'lardan (dosya formatını tanıtan sabit başlangıç byte'ları) okuyoruz ve her yükleme amacı için ayrı bir izin listesi tutuyoruz:
public static class FileSignatures
{
// Dosya adına ya da istemcinin gönderdiği Content-Type'a değil, ilk byte'lara bakar
public static FileFormat? Detect(ReadOnlySpan<byte> head) => head switch
{
[0xFF, 0xD8, 0xFF, ..] => FileFormat.Jpeg,
[0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => FileFormat.Png,
[(byte)'R', (byte)'I', (byte)'F', (byte)'F', _, _, _, _, (byte)'W', (byte)'E', (byte)'B', (byte)'P', ..] => FileFormat.WebP,
[(byte)'%', (byte)'P', (byte)'D', (byte)'F', (byte)'-', ..] => FileFormat.Pdf,
[(byte)'P', (byte)'K', 0x03, 0x04, ..] when IsEpub(head) => FileFormat.Epub,
_ => null,
};
// EPUB (OCF): ilk zip entry'si sıkıştırılmamış, extra field'sız bir "mimetype" dosyasıdır
private static bool IsEpub(ReadOnlySpan<byte> head) =>
head.Length >= 58
&& head[8] == 0 && head[9] == 0
&& head[28] == 0 && head[29] == 0
&& head[30..38].SequenceEqual("mimetype"u8)
&& head[38..58].SequenceEqual("application/epub+zip"u8);
public static bool IsAllowed(UploadPurpose purpose, FileFormat format) => (purpose, format) switch
{
(UploadPurpose.Cover, FileFormat.Jpeg or FileFormat.Png or FileFormat.WebP) => true,
(UploadPurpose.SampleChapter, FileFormat.Pdf or FileFormat.Epub) => true,
_ => false,
};
}
Örnek bölüm PDF ya da EPUB (e-kitap formatı; içi belirli kurallara uyan bir zip paketi) olabiliyor. Her zip PK\x03\x04 ile başladığı için EPUB'ı W3C'nin (World Wide Web Consortium) EPUB 3.3 spesifikasyonundaki kuralla ayırıyoruz: ilk dosya sıkıştırılmamış, extra field'ı olmayan ve içinde yalnız application/epub+zip yazan mimetype dosyası olmalı. PDF'te %PDF- imzasını sıfırıncı byte'ta istiyoruz; okuyucular biraz ilerisini de kabul ediyor, ama önündeki byte'lara bizim ihtiyacımız yok. Testi dotnet run --project tests/Bookstore.Api.Tests -- -showLiveOutput ile çalıştırdım (satır başındaki test adlarını kısalttım):
dune-cover.jpg Cover detected=Jpeg accepted=True
dune-cover.jpg Cover detected=unknown accepted=False
dune-cover.jpg Cover detected=Jpeg accepted=True
dune-sample.pdf SampleChapter detected=Pdf accepted=True
dune-sample.epub SampleChapter detected=Epub accepted=True
dune-sample.pdf SampleChapter detected=Jpeg accepted=False
İkinci satır, adı .jpg olan bir SVG (XML tabanlı vektör görsel formatı). SVG'nin içinde <script> olabiliyor ve tarayıcıda doğrudan açıldığında çalışıyor; yani bizim origin'imizden sunulan bir SVG, stored XSS (Cross-Site Scripting; saldırganın script'inin başka kullanıcıların tarayıcısında sitenin yetkileriyle çalışması) demek. Aynı nedenle HTML'i de hiç kabul etmiyoruz: izin listesinde olmayan her şey "unknown". Üçüncü satır açılıştaki dosya: imza kontrolü onu JPEG olarak görüyor ve geçiriyor. Magic byte "bu dosya hangi format" sorusunu cevaplıyor, "bu dosya zararsız mı" sorusunu değil.
Görseli Yeniden Kodlamak
Açılıştaki dosya bir polyglot (aynı byte'ların iki farklı formatta da geçerli sayıldığı dosya). Görüntü decoder'ı sondaki HTML'i görmez, ama dosyayı HTML olarak yorumlayan bir bağlam onu çalıştırır. EXIF (fotoğrafa gömülü kamera ve konum bilgisi) gibi metadata ve formatın yorum alanları da aynı şekilde taşıyıcı olabilir. OWASP'ın önerisi burada net: görseli decode edip izin verilen bir formata yeniden kodla. Elimizde yalnız pikseller kalır, dosyanın geri kalanı kopyalanmaz:
public byte[] Reencode(byte[] original)
{
using SKData data = SKData.CreateCopy(original);
using SKCodec codec = SKCodec.Create(data) ?? throw new InvalidUploadException("The image could not be read.");
// Yalnız başlık okunur: piksel limiti tek piksel decode edilmeden kontrol edilir
long pixels = (long)codec.Info.Width * codec.Info.Height;
if (pixels > options.Value.MaxCoverPixels)
{
throw new InvalidUploadException($"{codec.Info.Width}x{codec.Info.Height} is {pixels:N0} pixels, the limit is {options.Value.MaxCoverPixels:N0}.");
}
// Ham piksellere decode edip yepyeni bir dosya yazar: orijinalden başka hiçbir şey taşınmaz
using SKBitmap bitmap = SKBitmap.Decode(codec) ?? throw new InvalidUploadException("The image could not be decoded.");
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData webp = image.Encode(SKEncodedImageFormat.Webp, 85);
return webp.ToArray();
}
input : 374 bytes, Jpeg, contains <script: True
output: 566 bytes, WebP, contains <script: False
Piksel limiti decompression bomb'a (küçük dosyanın decode edilince devasa belleğe açılması) karşı; 40 milyon piksel bile decode edilince yaklaşık 160 MB tutuyor. Kütüphane olarak SkiaSharp'ı (MIT lisansı) seçtik. SixLabors ImageSharp'ın lisansını 15 Kasım 2026'da kontrol ettik: Six Labors Split License, Apache 2.0'ı yalnız açık kaynak projelere, dolaylı bağımlılığa, yıllık geliri 1 milyon doları aşmayan şirketlere ve kâr amacı gütmeyen kurumlara tanıyor; diğerleri ticari lisans almak zorunda. Resize, EXIF orientation ve metadata temizliğinin ayrıntısı .NET'te E-posta, PDF, Excel ve Dosya İşleri serisinin görsel işleme yazısında. PDF ve Office belgelerinde yeniden kodlamanın karşılığı CDR (Content Disarm and Reconstruct; belgeyi ayrıştırıp yalnız güvenli parçalarıyla yeniden üretmek); bunu çoğunlukla ticari ürünler yapıyor, biz PDF'i olduğu gibi saklayıp yalnız indirme olarak veriyoruz.
Arşivler: EPUB, Zip Bomb ve Zip Slip
EPUB bir zip olduğu için zip'in iki klasik sorunu onun da sorunu: zip bomb (küçük arşivin açılınca diski ya da belleği doldurması) ve Zip Slip (entry adındaki ../ ile dosyanın hedef klasörün dışına yazılması). Güvenli açmanın mekaniği .NET'te I/O İşlemleri serisinin compression ve arşivler yazısında. Biz EPUB'ı diske hiç açmıyoruz; yalnız dolaşıp ölçüyoruz:
foreach (ZipArchiveEntry entry in archive.Entries)
{
string name = entry.FullName;
if (name.StartsWith('/') || name.Contains('\\') || name.Contains(':') || name.Split('/').Contains(".."))
{
return $"Entry '{UploadFileName.Printable(name)}' has an unsafe path.";
}
if (NestedArchives.Contains(Path.GetExtension(name)))
{
return $"Entry '{UploadFileName.Printable(name)}' is a nested archive.";
}
if (entry.CompressedLength > 0 && entry.Length / entry.CompressedLength > o.MaxCompressionRatio)
{
return $"Entry '{UploadFileName.Printable(name)}' has compression ratio {entry.Length / entry.CompressedLength:N0}:1.";
}
// Başlıktaki boyutlar yalan söyleyebilir, gerçekten çıkan byte'ları say
await using Stream content = await entry.OpenAsync(ct);
int read;
while ((read = await content.ReadAsync(buffer, ct)) > 0)
{
expanded += read;
if (expanded > o.MaxExpandedBytes)
{
return $"Expanded size exceeds {o.MaxExpandedBytes:N0} bytes.";
}
}
}
Döngüden önce entry sayısını 2.000 ile sınırlıyoruz. Test, arşivleri bellekte üretiyor; bomb, 100 MB sıfırdan oluşan tek bir entry:
valid 288 bytes OK
bomb 102,163 bytes Entry 'OEBPS/ch1.xhtml' has compression ratio 1,028:1.
bomb, ratio off 102,163 bytes Expanded size exceeds 52,428,800 bytes.
traversal 306 bytes Entry '../../wwwroot/index.html' has an unsafe path.
nested 320 bytes Entry 'OEBPS/extra.zip' is a nested archive.
100 KB'lık dosya 100 MB'a açılıyor. Oran kontrolü onu ilk adımda yakalıyor; oranı kapattığımızda bile gerçek byte'ları sayan döngü 50 MB'ta duruyor, çünkü oran arşivin kendi beyan ettiği sayılardan hesaplanıyor. Arşiv içinde arşivi hiç kabul etmiyoruz; iç içe zip'ler hem limitleri katlamanın hem de taramayı zorlaştırmanın en kolay yolu. Diske açmasak da path kontrolünü yapıyoruz, çünkü bir gün bu EPUB'ı açan başka bir kod (web okuyucu, dönüştürücü) aynı güvene sahip olmayacak. EPUB 3 içerikleri JavaScript de içerebiliyor; EPUB'ı tarayıcıda gösteren bir okuyucu yazarsan onu da ayrı bir origin'den sun.
Zararlı Yazılım Taraması: ClamAV
Format ve yapı kontrolleri bilinen bir zararlı yazılımı yakalamaz; bunun için imza tabanlı bir tarayıcı gerekir. Yerelde ClamAV'ın resmî image'ını kullandım. Etiketteki imza veritabanı image ile geliyor, freshclam container içinde günceller; clamd'nin TCP port'unda authentication olmadığını dokümanı açıkça söylüyor, bu yüzden port yalnız 127.0.0.1'e bağlı:
docker run -d --name aspsecseries-clamav -p 127.0.0.1:52110:3310 clamav/clamav:1.5.4-debian
Uygulama clamd ile INSTREAM komutuyla konuşuyor; dosya diske yazılmadan parça parça gönderiliyor. Protokol küçük olduğu için ek paket kullanmadık:
// INSTREAM: her parça için 4 byte big-endian uzunluk + veri, sıfır uzunluk akışı bitirir
await stream.WriteAsync("zINSTREAM\0"u8.ToArray(), timeout.Token);
byte[] buffer = new byte[64 * 1024];
byte[] length = new byte[4];
int read;
while ((read = await content.ReadAsync(buffer, timeout.Token)) > 0)
{
BinaryPrimitives.WriteUInt32BigEndian(length, (uint)read);
await stream.WriteAsync(length, timeout.Token);
await stream.WriteAsync(buffer.AsMemory(0, read), timeout.Token);
}
BinaryPrimitives.WriteUInt32BigEndian(length, 0);
await stream.WriteAsync(length, timeout.Token);
using StreamReader reader = new(stream, Encoding.ASCII);
string reply = (await reader.ReadToEndAsync(timeout.Token)).TrimEnd('\0');
return reply switch
{
"stream: OK" => new ScanResult(true, null),
_ when reply.StartsWith("stream: ") && reply.EndsWith(" FOUND") => new ScanResult(false, reply["stream: ".Length..^" FOUND".Length]),
_ => throw new ScannerUnavailableException($"Unexpected clamd reply: {reply}"),
};
Testte "zararlı yazılım" olarak yalnız EICAR (European Institute for Computer Antivirus Research; antivirüs araştırmaları yapan bir Avrupa kuruluşu) test dosyasını kullanıyoruz: antivirüslerin tanımak üzere anlaştığı, hiçbir şey yapmayan 68 karakterlik bir metin. Kaynak dosyada bitişik durursa okurun bilgisayarındaki antivirüs test dosyasını karantinaya alır; bu yüzden onu çalışma anında birleştiriyoruz:
// Çalışma anında birleştirilir; böylece kaynak dosya okurun antivirüsünü tetiklemez
public static byte[] Eicar() => Encoding.ASCII.GetBytes(@"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + "EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*");
Uçtan uca testte her dosya SAS (Shared Access Signature; blob'a süreli ve sınırlı erişim veren imzalı URL) ile karantinaya yüklenip işleniyor:
dune-cover.jpg (polyglot) Approved
dune-cover.jpg (svg) Rejected Unknown file format.
dune-sample.epub Approved
dune-sample.epub (eicar) Infected Eicar-Test-Signature
dune-sample.pdf (25 MB) Rejected 26,214,477 bytes, the limit is 20,971,520.
Dördüncü satıra dikkat: EICAR'ı geçerli bir EPUB'ın içine, OEBPS/fonts/serif.ttf adıyla koyduk. İmza kontrolü ve arşiv limitleri dosyayı geçirdi; ClamAV arşivin içine bakıp yakaladı. Polyglot kapak onaylandı, çünkü onaylanan dosya artık yeniden kodlanmış WebP. ClamAV'ın resmî dokümanı clamd için en az 3 GiB, tercihen 4 GiB bellek istiyor. Tarayıcıyı API ile aynı container'a koyma, ayrı bir servis olarak çalıştır.
Azure'da: Defender for Storage Malware Scanning
Bu bölümde resmî belgelerden yararlanılmıştır. Microsoft Defender for Storage'ın malware scanning özelliği (dokümanın 22 Eylül 2026 güncellemesi), storage account'a yüklenen blob'ları Microsoft Defender Antivirus motoruyla tarıyor: on-upload modu her yükleme ya da değişiklikte, on-demand modu istediğin anda çalışıyor. arşivler dahil blob başına 50 GB'a kadar dosyaları tarıyor. Sonuç varsayılan olarak blob index tag'lerine yazılıyor (No threats found, Malicious, Error, Not scanned); ayrıca Defender for Cloud alert'i, Event Grid event'i ve Log Analytics kaydı alınabiliyor. Doküman index tag'lerin değiştirilebildiğini, bu yüzden tek güvenlik kontrolü olarak kullanılmaması gerektiğini söylüyor; akışını Event Grid event'iyle kur. Kötü amaçlı blob'u otomatik soft delete etme seçeneği de var. Sınırlar: client-side şifrelenmiş blob'lar ile append ve page blob'lar taranmıyor; tarama süresi dosyaya göre değişiyor ve çok büyük ya da karmaşık arşivlerde zaman aşımına düşebiliyor. Bizim akışımızda Defender, ClamAV'ın yerini alır: Event Grid event'i geldiğinde processor sonucu okur ve dosyayı onaylı container'a taşır ya da reddeder.
Karantina ve Durum Alanı: Akışın Tamamı

Yayınevinin yüklediği her dosya önce uploads-quarantine container'ına düşüyor; uygulamanın hiçbir okuma yolu bu container'dan dosya vermiyor. Kontroller bir BackgroundService'te, maliyeti artan sırayla çalışıyor: önce blob boyutu, sonra magic byte ve izin listesi, EPUB ise arşiv limitleri, ardından ClamAV, son olarak kapakta yeniden kodlama. Geçen dosya rastgele bir isimle uploads-approved'a yazılıyor ve karantinadaki kopya siliniyor. Durum (Processing, Approved, Rejected, Infected, ScanFailed) yükleme kaydında tutuluyor; bu yazıda bellekteki bir store, gerçekte bir tablo. Hata durumundaki davranış en kritik kısım:
try
{
await ProcessAsync(record, stoppingToken);
}
catch (InvalidUploadException ex)
{
(record.Status, record.Reason) = (UploadStatus.Rejected, ex.Message);
await Quarantine.GetBlobClient(record.QuarantineBlob).DeleteIfExistsAsync(cancellationToken: stoppingToken);
}
catch (Exception ex) when (ex is ScannerUnavailableException or SocketException or IOException or OperationCanceledException
&& !stoppingToken.IsCancellationRequested)
{
// Fail closed: dosya karantinada kalır ve hiçbir zaman sunulmaz
(record.Status, record.Reason) = (UploadStatus.ScanFailed, ex.GetType().Name);
}
Tarayıcı cevap vermezse dosya "temiz" sayılmıyor. Bunu, bağlantıyı kabul edip hiç cevap vermeyen bir TcpListener'ı testte kendimiz açıp 2 saniyelik timeout ile denedik:
info: Bookstore.Api.Uploads.UploadProcessor[0]
Upload 28207b52-96b8-4304-a8cf-5a25e4b9aa9b (dune.pdf) finished as ScanFailed: OperationCanceledException
status ScanFailed (OperationCanceledException), download -> 409
Log satırındaki dosya adı, istemcinin gönderdiği ad değil, UploadFileName.ForDisplay'den geçmiş hâli: path'i atılmış, kontrol karakterleri silinmiş, 100 karaktere kısaltılmış. ..\..\appsettings.json\r\n[INF] Admin login ok adıyla gelen bir dosya log'a appsettings.json[INF] Admin login ok olarak yazılıyor; satır sonu karakterleri silindiği için sahte bir log satırı oluşturamıyor.
Blob'a Doğrudan Yükleme: SAS'ı Daraltmak
20 MB'lık bir PDF'i API'den geçirmek yerine tarayıcının doğrudan Blob Storage'a yüklemesi hem Kestrel'i hem bant genişliğini rahatlatıyor. SAS üretmenin ve CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e istek atmasına izin veren kurallar) ayarlarının mekaniği .NET'te E-posta, PDF, Excel ve Dosya İşleri serisinin dosya depolama yazısında, user delegation SAS ise Cloud Temelleri serisinin Cloud Storage yazısında. Burada önemli olan, SAS'ın neye izin verdiği:
// Tek blob, yalnız oluşturma, birkaç dakika, karantina container'ında
public Uri ForUpload(UploadRecord record)
{
UploadOptions o = options.Value;
BlobClient blob = blobs.GetBlobContainerClient(o.QuarantineContainer).GetBlobClient(record.QuarantineBlob);
BlobSasBuilder sas = new(BlobSasPermissions.Create, time.GetUtcNow().Add(o.UploadSasLifetime))
{
BlobContainerName = blob.BlobContainerName,
BlobName = blob.Name,
Resource = "b",
Protocol = o.SasProtocol,
};
return blob.GenerateSasUri(sas);
}
Yerelde Azurite'i kendi ürettiğim key'e sahip bir hesapla çalıştırdım. SDK'nın gönderdiği servis sürümü (2026-10-06) Azurite 3.37.0'ın tanıdığından yeni olduğu için --skipApiVersionCheck gerekiyor; testler bağlantı bilgilerini environment variable'lardan okuyor:
docker run -d --name aspsecseries-azurite -p 127.0.0.1:52109:10000 -e AZURITE_ACCOUNTS="bookstoredev:<base64-key>" mcr.microsoft.com/azure-storage/azurite:3.37.0 azurite-blob --blobHost 0.0.0.0 --skipApiVersionCheck
$env:Storage__ConnectionString = "DefaultEndpointsProtocol=http;AccountName=bookstoredev;AccountKey=<base64-key>;BlobEndpoint=http://127.0.0.1:52109/bookstoredev;"
$env:ClamAv__Port = "52110"
dotnet run --project tests/Bookstore.Api.Tests -- -showLiveOutput
upload url: http://127.0.0.1:52109/bookstoredev/uploads-quarantine/d19f142616c448408fdadabc4c0d067e?sv=2026-10-06&spr=https,http&se=2026-11-15T11%3A33%3A11Z&sr=b&sp=c&sig=<signature>
PUT own blob -> 201
PUT own blob again -> 403 AuthorizationPermissionMismatch
PUT another blob name -> 403 AuthorizationFailure
GET with upload SAS -> 403 AuthorizationPermissionMismatch
sr=b SAS'ı tek blob'a bağlıyor: aynı imzayla başka bir isme yazmak reddediliyor. sp=c, yani Create izni, dokümanda yeni blob yazmak olarak tanımlı; Azurite ikinci PUT'u, yani yüklenen dosyanın üzerine yazmayı 403 ile reddetti. Okuma izni yok, bu yüzden SAS linki sızsa bile dosyayı dağıtmak için kullanılamıyor. Production'da spr=https kalmalı; testte Azurite düz HTTP dinlediği için ayarla açtık.
Ama SAS'ın yapamadığı bir şey var: boyut ve içerik sınırlamak. Yukarıdaki 25 MB'lık dosya için yükleme isteğinde 1.024 byte beyan ettik ve Blob Storage onu sorgusuz kabul etti. Kestrel limitleri bu yolda devreye girmiyor; bu yüzden processor'ın ilk işi blob'un gerçek boyutuna bakmak. SAS üreten endpoint'i rate limiting ile sınırla, karantina container'ına bir gün sonra silen bir lifecycle policy koy; yoksa tamamlanmamış yüklemeler depoda birikir. Bu tür kaynak tüketimi riskleri API Güvenliği serisinin kaynak tüketimini anlatan yazısında.
İndirme: Yetki Kontrolü ve Güvenli Header'lar
Onaylı dosyalar da public değil. İndirme endpoint'i önce sahipliği kontrol ediyor, sonra beş dakikalık, yalnız okuma izinli bir SAS'a yönlendiriyor. Başka yayınevinin dosyası, hiç olmayan bir dosya gibi 404 alıyor; böylece ID'lerin varlığı da sızmıyor:
uploads.MapGet("/{id:guid}/download", (Guid id, ClaimsPrincipal user, UploadStore store, UploadSasIssuer sas) =>
Find(store, id, user) switch
{
null => Results.NotFound(),
{ Status: UploadStatus.Approved } record => Results.Redirect(sas.ForDownload(record).ToString()),
var record => Results.Conflict(new UploadStatusResponse(id, record.Status, null)),
});
BlobSasBuilder sas = new(BlobSasPermissions.Read, time.GetUtcNow().Add(o.DownloadSasLifetime))
{
BlobContainerName = blob.BlobContainerName,
BlobName = blob.Name,
Resource = "b",
Protocol = o.SasProtocol,
ContentType = contentType,
ContentDisposition = $"attachment; filename=\"{record.Isbn}-{record.Purpose.ToString().ToLowerInvariant()}.{extension}\"",
};
ContentType ve ContentDisposition, SAS'a rsct ve rscd parametreleri olarak imzalanıyor ve Blob Storage yanıtta blob'a kayıtlı değerlerin yerine bunları döndürüyor. Değerler tespit ettiğimiz formattan ve ISBN'den (kitabın uluslararası standart numarası) üretiliyor; istemcinin gönderdiği Content-Type'ı ve dosya adını kullanmıyoruz, header'a satır sonu ya da tırnak sokulamıyor. Akış testinin çıktısı:
PUT with SAS -> 201
download before scan -> 409
status -> Approved
download, other publisher -> 404
download -> 302 then 200
Content-Type: application/pdf
Content-Disposition: attachment; filename="9780441013593-samplechapter.pdf"
Dosyalar ayrı bir origin'den, yani storage account'tan ya da önündeki CDN'den (içeriği kullanıcıya yakın sunuculardan dağıtan ağ) geldiği için, bir şey kaçsa bile script'i Bookstore'un cookie'lerine erişemiyor. Dosyayı uygulamanın kendisinden veriyorsan Results.File(stream, contentType, fileDownloadName) aynı attachment header'ını üretir; buna serinin security header yazısındaki middleware'den X-Content-Type-Options: nosniff ekle ki tarayıcı içeriğe bakıp türü kendisi tahmin etmesin.
Üretimde Dikkat
Tarama kuyruğunu izle: Processing'de bekleyen kayıt sayısı ve en eski kaydın yaşı birer metrik olsun, gözlem yazısındaki alarm kurallarına ekle. ScanFailed kayıtlarını bir retry job'ı tekrar kuyruğa koysun; tarayıcı uzun süre düşükse yüklemeleri tamamen durdurmak, taranmamış dosyayı kabul etmekten iyidir.
ClamAV imzaları günde bir kez güncelleniyor ve güncelleme sırasında clamd'nin belleği kısa süreliğine iki katına çıkabiliyor; container'ın bellek limitini buna göre ver. İmza tabanlı tarama yalnız bilinen zararlıları yakalar: yeniden kodlama, izin listesi ve ayrı origin bu yüzden taramanın yerine değil, yanında.
Reddedilen ve Infected dosyaların blob'unu bu örnekte hemen siliyoruz. Bir olay incelemesi için kanıt gerekiyorsa, onları erişimi kısıtlı ayrı bir container'a ve kısa saklama süresiyle taşı.
Sık Düşülen Tuzaklar
Kontrolü yalnız istemcide yapmak. Angular'daki accept=".jpg,.pdf" ve boyut kontrolü kullanıcı deneyimi için; SAS URL'ini alan biri curl ile istediğini yükler, testteki 25 MB'lık dosya gibi.
Dosyayı taramadan önce sunmak. Taramanın arkadan geldiği bir akışta dosya, tarama sürdüğü sürece dağıtılabilir. Onaylı container'a yazmak tek giriş kapısı olmalı.
Yüklenen dosya adını olduğu gibi path'e ve log'a yazmak. Path'te path traversal'a (Web Güvenliği serisinin path traversal yazısı), log'da sahte satırlara, Content-Disposition'da header injection'a yol açar. Saklarken ID, gösterirken temizlenmiş ad, indirirken üretilmiş ad kullan.
Magic byte kontrolünü yeterli saymak. Açılıştaki polyglot imza kontrolünden geçti; onu zararsız yapan yeniden kodlamaydı.
Küçük Bir Deneme
★☆☆ InspectionTests'e bir decompression bomb ekle: küçük ama başlığında 20000x20000 yazan bir görsel üret ve CoverReencoder'ın onu decode etmeden reddettiğini doğrula.
★★☆ ClamAV container'ını docker pause ile durdur, bir örnek bölüm yükle ve kaydın ScanFailed'a düştüğünü gör. Sonra ScanFailed kayıtlarını beş dakikada bir yeniden kuyruğa koyan bir retry job'ı yaz ve container'ı devam ettirince kaydın Approved olduğunu test et.
★★★ İndirme endpoint'ine bir kitabın onaylı kapaklarını inline olarak veren ayrı bir yol ekle: kapaklar <img> ile gösterilecek. Hangi header'ları ve hangi cache süresini seçtiğini, CDN'de imzalı URL'in nasıl cache'leneceğini gerekçesiyle yaz.
Bu yazıyla seriyi tamamlıyoruz. Authorization'ın iç yapısından Data Protection'a, rate limiting'den header'lara, antiforgery'den Kestrel limitlerine, gözlemden .NET 11'in yeniliklerine ve son olarak dosya yüklemeye kadar, framework'ün güvenlik araçlarının hangi kararı nerede verdiğini ve production'da nasıl ayarlanacağını gördük. Kullanıcıyı kimin doğruladığı ve ona hangi yetkilerin verileceği Authentication ve Authorization serisinin, tarayıcı tarafındaki CSP (tarayıcının hangi kaynakları yükleyebileceğini belirleyen güvenlik header'ı) ayrıntısı da Frontend Güvenliği serisinin konusu.