ASP.NET Core Web API'de Dosya Yükleme ve İndirme: IFormFile, Limitler ve Streaming
IFormFile ile ürün görseli yüklüyor, path traversal ve sahte uzantıya karşı dosyayı koruyoruz. Kestrel ve form limitlerini, PhysicalFile ile indirmeyi ve MultipartReader ile streaming'i görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Bir önceki yazıda ürün listesini sayfalara böldük, filtreledik ve sıraladık. Katalog artık büyük listeleri rahat taşıyor. Ama bir ürün sadece isim ve fiyattan ibaret değil; bir görseli, belki bir de PDF kullanım kılavuzu olur. Bu yazıda API'ye dosya almayı ve dosyayı geri vermeyi konuşacağız.
Başlamadan bir soru. Birazdan yazacağımız ilk upload endpoint'ine hiçbir ayar yapmadan 40 MB'lık bir dosya gönderiyorsun. Sunucu ne döner? 413 Payload Too Large mı, 500 mü, yoksa dosyayı kabul edip 200 mü? Cevap, limitleri konuştuğumuz bölümde.
IFormFile ile İlk Upload
Önce en saf hâliyle başlayalım. Bu kodu bilerek eksik yazıyorum, birazdan neden tehlikeli olduğunu göreceğiz:
using Microsoft.AspNetCore.Mvc;
namespace ProductCatalog.Controllers;
[ApiController]
[Route("api/products/{productId:int}/image")]
public class ProductImagesController : ControllerBase
{
private readonly string _storagePath;
public ProductImagesController(IWebHostEnvironment environment)
{
// Dosyalar projenin içindeki uploads klasörüne yazılacak
_storagePath = Path.Combine(environment.ContentRootPath, "uploads");
Directory.CreateDirectory(_storagePath);
}
[HttpPost]
public async Task<IActionResult> Upload(int productId, IFormFile file)
{
// DİKKAT: dosya adını doğrudan istemciden alıyoruz
var path = Path.Combine(_storagePath, file.FileName);
await using var stream = System.IO.File.Create(path);
await file.CopyToAsync(stream);
return Ok(new { file.FileName, file.Length, file.ContentType });
}
}
IFormFile, multipart/form-data ile gelen bir dosyanın ASP.NET Core'daki karşılığı. [ApiController] attribute'u bu tipteki parametrenin kaynağını kendisi [FromForm] olarak çıkarır; ayrıca yazmana gerek yok. Tek şart, parametre adının form alanının adıyla eşleşmesi: burada file.
System.IO.File.Create yazdığımız satıra dikkat et. Controller'ın içinde sadece File.Create yazarsan derleyici seni durdurur, çünkü ControllerBase class'ında File() adında bir method var ve isim önce ona çözülür:
error CS0119: 'ControllerBase.File(byte[], string)' is a method, which is not valid in the given context
Uygulamayı çalıştır ve curl ile bir görsel gönder:
curl -F "file=@keyboard.png" http://localhost:5080/api/products/1/image
{"fileName":"keyboard.png","length":179,"contentType":"image/png"}
FileName, Length ve ContentType ilk bakışta işini görür. Dosyayı hiç göndermezsen [ApiController] seni 400 ile karşılar ve errors içinde "file": ["The file field is required."] yazar. Nullable reference type açık olduğu için IFormFile parametresi zorunlu sayılıyor.
Dosya Adına Asla Güvenme
FileName ve ContentType değerlerini sunucu üretmiyor; istemci ne yazarsa o geliyor. curl ile dosya adını değiştirmek tek bir parametre:
curl -F "file=@keyboard.png;filename=../hacked.png" http://localhost:5080/api/products/1/image
{"fileName":"../hacked.png","length":179,"contentType":"image/png"}
Response masum görünüyor ama diske baktığında hacked.png dosyasının uploads klasörüne değil, bir üst klasöre, yani projenin köküne, appsettings.json dosyasının yanına yazıldığını görürsün. Buna path traversal denir. Aynı yöntemle sunucudaki başka bir dosyanın üzerine yazmak da mümkün.
Otel resepsiyonu gibi düşün. Misafir "beni 1205'e değil, müdürün odasına yerleştirin" diyemez; oda numarasını resepsiyon verir. Dosya adını da sen üretirsin: product-1.png ya da bir Guid. Kullanıcının verdiği orijinal adı göstermek istiyorsan onu veritabanında metadata olarak sakla, path'te asla kullanma.
İkinci yalan ContentType. Bir shell script'in adını evil.png yapıp gönderirsen curl uzantıya bakar ve image/png gönderir. İçeriğin gerçekten görsel olup olmadığını anlamanın yolu dosyanın ilk byte'larına bakmak. Her formatın bir imzası (file signature, magic number) var: PNG dosyaları 89 50 4E 47 0D 0A 1A 0A ile, JPEG dosyaları FF D8 FF ile başlar. Uzantıyı bir allow list ile, içeriği de bu imzayla kontrol edeceğiz.
Boyut Limitleri: Üst Üste Üç Kapı
Şimdi baştaki sorunun cevabı. İlk endpoint'e 40 MB gönderdiğinde 413 değil, 400 alırsın:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"": [
"Failed to read the request form. Request body too large. The max request body size is 30000000 bytes."
]
}
}
(type ve traceId alanlarını çıkardım.) Kestrel'in varsayılan request body limiti 30.000.000 byte, yani yaklaşık 28,6 MB. Limit aşılınca Kestrel bir exception fırlatıyor; ama form'u okuyan MVC'nin model binding katmanı olduğu için exception yakalanıp ModelState'e boş bir key ile ekleniyor, [ApiController] da bunu bildiğimiz 400'e çeviriyor. Aynı 40 MB'ı Request.Body'yi doğrudan okuyan bir endpoint'e gönderdiğimde Kestrel'in kendi cevabını, 413'ü aldım.
Dosya senin action'ına ulaşana kadar üç kapıdan geçer. Birincisi Kestrel'in MaxRequestBodySize limiti (30.000.000 byte). İkincisi form okuyucunun FormOptions.MultipartBodyLengthLimit değeri; varsayılanı 134.217.728 byte, yani 128 MB. Üçüncüsü senin iş kuralın: ürün görseli için 2 MB fazlasıyla yeter. Limitleri action bazında attribute ile daraltabilirsin:
[HttpPost]
[RequestSizeLimit(3 * 1024 * 1024)]
[RequestFormLimits(MultipartBodyLengthLimit = 3 * 1024 * 1024)]
Tüm uygulama için ise Program.cs içinde ayarlarsın:
using Microsoft.AspNetCore.Http.Features;
var builder = WebApplication.CreateBuilder(args);
// Tüm request body'ler için Kestrel limiti: 10 MB
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;
});
// Multipart form okuyucunun limiti: 10 MB
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
});
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Action'daki attribute global ayarı ezer. Global limiti 10 MB yaptığım hâlde [RequestSizeLimit(200 * 1024 * 1024)] taşıyan bir action 40 MB'lık dosyayı kabul etti. Request limitini 3 MB, dosya limitini 2 MB tuttuğumuza dikkat et: multipart body'de dosyanın yanında boundary satırları ve header'lar da var, request her zaman dosyadan biraz büyüktür.
Güvenli Upload Action'ı
Üç kapıyı ve iki güvenlik kontrolünü birleştirince action şu hâli alıyor:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
namespace ProductCatalog.Controllers;
[ApiController]
[Route("api/products/{productId:int}/image")]
public class ProductImagesController : ControllerBase
{
private const long MaxFileSize = 2 * 1024 * 1024;
// İzin verilen uzantılar ve dosyanın başında olması gereken imza byte'ları
private static readonly Dictionary<string, byte[]> Signatures = new()
{
[".png"] = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A },
[".jpg"] = new byte[] { 0xFF, 0xD8, 0xFF },
[".jpeg"] = new byte[] { 0xFF, 0xD8, 0xFF }
};
private readonly string _storagePath;
public ProductImagesController(IWebHostEnvironment environment)
{
_storagePath = Path.Combine(environment.ContentRootPath, "uploads");
Directory.CreateDirectory(_storagePath);
}
[HttpPost]
[RequestSizeLimit(3 * 1024 * 1024)]
[RequestFormLimits(MultipartBodyLengthLimit = 3 * 1024 * 1024)]
public async Task<IActionResult> Upload(int productId, IFormFile file, CancellationToken cancellationToken)
{
if (file.Length == 0 || file.Length > MaxFileSize)
{
return BadRequest("File size must be between 1 byte and 2 MB.");
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!Signatures.TryGetValue(extension, out var signature))
{
return BadRequest("Only .png, .jpg and .jpeg files are allowed.");
}
if (!await HasSignatureAsync(file, signature, cancellationToken))
{
return BadRequest("File content does not match its extension.");
}
// Ürünün eski görseli varsa (belki farklı uzantıyla) önce onu sil
var existing = FindImage(productId);
if (existing is not null)
{
System.IO.File.Delete(existing);
}
// Dosya adını istemci değil, sunucu üretiyor
var fileName = $"product-{productId}{extension}";
var path = Path.Combine(_storagePath, fileName);
await using (var stream = System.IO.File.Create(path))
{
await file.CopyToAsync(stream, cancellationToken);
}
return CreatedAtAction(nameof(GetImage), new { productId }, new { fileName, file.Length });
}
Dosya adından sadece uzantıyı alıyoruz, o da allow list'ten geçmek zorunda. İmza kontrolü ise küçük bir yardımcı method:
private string? FindImage(int productId) =>
Directory.EnumerateFiles(_storagePath, $"product-{productId}.*").FirstOrDefault();
private static async Task<bool> HasSignatureAsync(IFormFile file, byte[] signature, CancellationToken cancellationToken)
{
await using var stream = file.OpenReadStream();
var header = new byte[signature.Length];
var read = await stream.ReadAsync(header, cancellationToken);
return read == signature.Length && header.SequenceEqual(signature);
}
}
ReadAsync istediğin kadar byte'ı okumayı garanti etmez, bu yüzden okunan sayıyı da karşılaştırıyoruz. Tam olarak N byte okuyan ReadExactlyAsync .NET 7 ile geldi; .NET 6'da derlemeye çalışırsan CS1061 alırsın. CancellationToken'ı da CopyToAsync'e geçiriyoruz ki istemci bağlantıyı koparırsa kopyalama boşuna sürmesin.
Testler artık beklediğimiz gibi. Normal görsel 201 Created ve Location: http://localhost:5080/api/products/1/image header'ıyla dönüyor. Adı evil.png olan script File content does not match its extension., .sh uzantılı dosya Only .png, .jpg and .jpeg files are allowed. cevabını alıyor. ../hacked.png adıyla gönderilen dosya ise artık sadece product-1.png olarak kaydediliyor.
Dosyayı Geri Vermek: PhysicalFile ve File
Upload'ın öbür yüzü indirme. İki farklı ihtiyaç var: görseli tarayıcıda göstermek ve dosyayı indirtmek.
[HttpGet]
public IActionResult GetImage(int productId)
{
var path = FindImage(productId);
if (path is null)
{
return NotFound();
}
// Uzantıdan MIME tipini bul: .png -> image/png
var provider = new FileExtensionContentTypeProvider();
if (!provider.TryGetContentType(path, out var contentType))
{
contentType = "application/octet-stream";
}
return PhysicalFile(path, contentType, enableRangeProcessing: true);
}
[HttpGet("download")]
public IActionResult DownloadImage(int productId)
{
var path = FindImage(productId);
if (path is null)
{
return NotFound();
}
// Stream'i FileStreamResult kapatır, using yazmıyoruz
var stream = System.IO.File.OpenRead(path);
return File(stream, "application/octet-stream", Path.GetFileName(path));
}
PhysicalFile diskteki bir dosyayı mutlak path ile, belleğe almadan stream eder. enableRangeProcessing: true ile istemci dosyanın bir parçasını isteyebilir; video oynatıcılar ve yarıda kalan indirmeler bunu kullanır:
curl -i -r 0-7 http://localhost:5080/api/products/1/image
HTTP/1.1 206 Partial Content
Content-Length: 8
Content-Type: image/png
Date: Tue, 03 Nov 2026 21:18:07 GMT
Server: Kestrel
Accept-Ranges: bytes
Content-Range: bytes 0-7/179
Last-Modified: Tue, 03 Nov 2026 21:18:07 GMT
File(stream, contentType, fileDownloadName) overload'u ise üçüncü parametre sayesinde Content-Disposition header'ı ekler ve tarayıcı dosyayı göstermek yerine indirir:
HTTP/1.1 200 OK
Content-Length: 179
Content-Type: application/octet-stream
Date: Tue, 03 Nov 2026 21:18:07 GMT
Server: Kestrel
Content-Disposition: attachment; filename=product-1.png; filename*=UTF-8''product-1.png
File(byte[], ...) overload'u da var ama bütün dosyayı belleğe almanı ister. Küçük, zaten bellekte üretilmiş içerik için (örneğin anlık oluşturulan bir CSV) uygundur; diskteki büyük dosyalar için stream ya da PhysicalFile kullan.
Büyük Dosyalar ve Streaming
IFormFile kolay ama bir bedeli var: action'ın çalışmadan önce bütün dosya okunur. İlk 64 KB bellekte tutulur (FormOptions.MemoryBufferThreshold varsayılanı 65.536), fazlası geçici bir dosyaya yazılır. 50 MB'lık PDF kılavuzları için bu, her upload'da diske iki kez yazmak demek. Bunun yerine multipart bölümlerini geldikçe okuyup doğrudan hedef dosyaya akıtabiliriz:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
using ProductCatalog.Filters;
namespace ProductCatalog.Controllers;
[ApiController]
[Route("api/products/{productId:int}/manual")]
public class ProductManualsController : ControllerBase
{
private readonly string _storagePath;
public ProductManualsController(IWebHostEnvironment environment)
{
_storagePath = Path.Combine(environment.ContentRootPath, "uploads");
Directory.CreateDirectory(_storagePath);
}
[HttpPost]
[RequestSizeLimit(200 * 1024 * 1024)]
[DisableFormValueModelBinding]
public async Task<IActionResult> Upload(int productId, CancellationToken cancellationToken)
{
if (!MediaTypeHeaderValue.TryParse(Request.ContentType, out var mediaType) ||
!mediaType.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Expected a multipart/form-data request.");
}
var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
if (string.IsNullOrEmpty(boundary))
{
return BadRequest("Missing multipart boundary.");
}
// Body'yi bölüm bölüm okuyoruz; hiçbir şey önceden belleğe alınmıyor
var reader = new MultipartReader(boundary, Request.Body);
var section = await reader.ReadNextSectionAsync(cancellationToken);
while (section is not null)
{
if (ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition) &&
disposition.IsFileDisposition())
{
var path = Path.Combine(_storagePath, $"manual-{productId}.pdf");
await using var target = System.IO.File.Create(path);
await section.Body.CopyToAsync(target, cancellationToken);
return Ok(new { fileName = Path.GetFileName(path), length = target.Length });
}
section = await reader.ReadNextSectionAsync(cancellationToken);
}
return BadRequest("No file section found.");
}
}
[DisableFormValueModelBinding] satırı olmadan bu kodu ilk çalıştırdığımda şu exception'ı aldım:
System.IO.IOException: Unexpected end of Stream, the content may have already been read by another component.
Sebep route'taki productId. Action'ın bağlanacak bir parametresi olunca MVC value provider'ları hazırlıyor; request'in content type'ı form olduğu için FormValueProviderFactory form'u, yani bütün body'yi okuyup tüketiyor. Bizim MultipartReader sıraya geldiğinde okunacak bir şey kalmıyor. Çözüm, filter'ları anlattığımız yazıda gördüğümüz resource filter'lardan biri; model binding'den önce çalıştığı için form okuyan value provider'ları listeden çıkarabiliyor:
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace ProductCatalog.Filters;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
public void OnResourceExecuting(ResourceExecutingContext context)
{
// Form'u okuyup body'yi tüketecek value provider'ları kaldır
context.ValueProviderFactories.RemoveType<FormValueProviderFactory>();
context.ValueProviderFactories.RemoveType<FormFileValueProviderFactory>();
context.ValueProviderFactories.RemoveType<JQueryFormValueProviderFactory>();
}
public void OnResourceExecuted(ResourceExecutedContext context)
{
}
}
Filter'ı ekledikten sonra 50 MB'lık dosya sorunsuz geçti: {"fileName":"manual-1.pdf","length":52428800}. Bu yolda FormOptions limitleri devreye girmez, çünkü form okuyucuyu hiç kullanmıyoruz; limiti [RequestSizeLimit] ve kendi kontrollerin belirler.
Sık Düşülen Tuzaklar
file.FileName'i path'e koymak. Yukarıda gördük; sunucu adı üretir, orijinal ad sadece metadata'dır.
ContentType'a ya da uzantıya tek başına güvenmek. İkisi de istemcinin beyanı. İmza kontrolü yap; kullanıcıların yüklediği dosyaları başka sistemlere açıyorsan bir antivirüs taramasını da düşün.
Yüklenen dosyaları wwwroot altına koymak. UseStaticFiles açıksa birinin yüklediği bir HTML ya da JavaScript dosyası senin domain'inden sunulur. Dosyaları dışarıya doğrudan açık olmayan bir yerde tut, production'da ayrı bir disk ya da blob storage kullan.
Büyük dosyayı MemoryStream'e ya da byte[]'e almak. Aynı anda gelen birkaç upload sunucunun belleğini bitirir. Dosyayı stream olarak hedefe akıt.
Sadece [RequestSizeLimit]'i büyütmek. 300 MB'a izin veren bir IFormFile action'ına 130 MB'lık dosya gönderdiğimde yine 400 aldım: Failed to read the request form. Multipart body length limit 134217728 exceeded. İkinci kapı olan MultipartBodyLengthLimit hâlâ 128 MB'tı.
Küçük Bir Deneme
★☆☆ ProductImagesController'a bir DELETE action'ı ekle: görsel varsa silip 204, yoksa 404 dönsün.
★★☆ .webp desteği ekle. WebP dosyaları RIFF ile başlar ama asıl imza offset 8'de duran WEBP dörtlüsüdür; HasSignatureAsync'i offset alacak şekilde genişlet ve sahte bir .webp dosyasıyla dene.
★★★ Kılavuz endpoint'inde ilk 5 byte'ın %PDF- olduğunu stream ederken kontrol et. İmza tutmazsa ya da istemci bağlantıyı yarıda keserse diskte yarım dosya bırakma.
Şu an bir eksik göze batıyor: ürün görselini isteyen herkes değiştirebiliyor. API, isteği kimin attığını bilmiyor. Bir sonraki yazıda authentication'ı ve JWT'yi ele alıp kullanıcının kim olduğunu kanıtlayan bir token üretecek, her istekte de o token'ı doğrulayacağız.