File upload handling in a Web API requires careful attention to security (rejecting dangerous file types), performance (streaming large files without loading them into memory), and storage abstraction (swapping local disk for Azure Blob without changing controllers). A file upload endpoint that simply saves whatever the client sends — any file type, any size, any name — is a security vulnerability. Production file handling enforces size limits, validates content type beyond just the MIME header, and uses a storage abstraction that works identically in development and production.
Secure File Upload Endpoint
// ── Configure Kestrel request size limits ──────────────────────────────────
builder.WebHost.ConfigureKestrel(opts =>
opts.Limits.MaxRequestBodySize = 50 * 1024 * 1024); // 50MB max
// Also override per-action if needed:
[RequestSizeLimit(5 * 1024 * 1024)] // 5MB for this endpoint
[RequestFormLimits(MultipartBodyLengthLimit = 5 * 1024 * 1024)]
// ── File upload action ─────────────────────────────────────────────────────
[HttpPost("cover-image")]
[Authorize]
[RequestSizeLimit(5 * 1024 * 1024)]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileUploadResult), 200)]
[ProducesResponseType(typeof(ValidationProblemDetails), 400)]
public async Task<ActionResult<FileUploadResult>> UploadCoverImage(
[FromRoute] int id,
IFormFile file,
CancellationToken ct)
{
// 1. Validate file presence
if (file is null || file.Length == 0)
return BadRequest("No file provided.");
// 2. Validate MIME type (extension check is not enough — client controls it)
var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp" };
if (!allowedTypes.Contains(file.ContentType.ToLower()))
return BadRequest($"File type '{file.ContentType}' not allowed. Use JPEG, PNG, or WebP.");
// 3. Validate by magic bytes (file signature) — prevents MIME spoofing
if (!await IsValidImageAsync(file, ct))
return BadRequest("File content does not match the declared image type.");
// 4. Upload via storage abstraction (local or cloud)
var result = await _fileStorage.UploadAsync(
containerName: "cover-images",
fileName: GenerateUniqueFileName(file.FileName),
stream: file.OpenReadStream(),
contentType: file.ContentType,
ct: ct);
// 5. Update the post with the new cover URL
await _postService.UpdateCoverImageAsync(id, result.Url, ct);
return Ok(result);
}
// ── Magic bytes validation — verifies actual file content ─────────────────
private static async Task<bool> IsValidImageAsync(IFormFile file, CancellationToken ct)
{
using var stream = file.OpenReadStream();
var header = new byte[4];
await stream.ReadExactlyAsync(header, ct);
return file.ContentType switch
{
"image/jpeg" => header[0] == 0xFF && header[1] == 0xD8, // JPEG: FF D8
"image/png" => header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E, // PNG: 89 50 4E
"image/webp" => header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46, // RIFF
_ => false,
};
}
// ── Unique file name — prevents overwrite and path traversal ───────────────
private static string GenerateUniqueFileName(string originalName)
{
var extension = Path.GetExtension(originalName).ToLowerInvariant();
// Whitelist of safe extensions
if (!new[] { ".jpg", ".jpeg", ".png", ".webp" }.Contains(extension))
extension = ".bin";
return $"{Guid.NewGuid():N}{extension}"; // e.g., a4f3...1c9.jpg
}
file.ContentType) is insufficient — the client controls this value and can claim an MP4 is “image/jpeg”. Magic byte validation reads the first few bytes of the actual file content and compares them against known signatures. JPEG files always start with FF D8, PNG with 89 50 4E 47, etc. This cannot be spoofed without modifying the file itself. For production, additionally use a virus scanning service (ClamAV, Windows Defender ATP) that scans the byte content before storage.file.OpenReadStream() and stream directly to storage rather than loading the entire file into memory with file.CopyToAsync(memoryStream). For a 50MB file, loading into memory allocates 50MB on the .NET heap — at 10 concurrent uploads, that is 500MB of large object heap allocations. Streaming directly passes the data through a small buffer (typically 4–8KB) regardless of file size. Azure Blob Storage’s SDK handles chunked streaming upload automatically.file.FileName directly as the storage path. A malicious client can send filenames with path traversal sequences (../../etc/passwd), null bytes, or excessively long names. Always generate a new UUID-based filename for storage. If you need to preserve the original name for display purposes, store it in the database alongside the generated storage key — never in the file system path.IFileStorageService Abstraction
// ── Abstraction — swap local for Azure without changing controllers ─────────
public interface IFileStorageService
{
Task<FileUploadResult> UploadAsync(
string containerName, string fileName, Stream stream,
string contentType, CancellationToken ct = default);
Task<Stream> DownloadAsync(string containerName, string fileName, CancellationToken ct = default);
Task DeleteAsync(string containerName, string fileName, CancellationToken ct = default);
string GetPublicUrl(string containerName, string fileName);
}
public record FileUploadResult(string FileName, string Url, long SizeBytes);
// ── Local disk implementation (development) ───────────────────────────────
public class LocalFileStorageService(IWebHostEnvironment env) : IFileStorageService
{
private readonly string _root = Path.Combine(env.WebRootPath, "uploads");
public async Task<FileUploadResult> UploadAsync(
string container, string fileName, Stream stream,
string contentType, CancellationToken ct = default)
{
var dir = Path.Combine(_root, container);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, fileName);
await using var fs = File.Create(path);
await stream.CopyToAsync(fs, ct);
return new FileUploadResult(fileName, $"/uploads/{container}/{fileName}", fs.Length);
}
// ... Download, Delete, GetPublicUrl implementations
}
Common Mistakes
Mistake 1 — Using client-provided filename directly (path traversal attack)
❌ Wrong — Path.Combine(uploadDir, file.FileName); attacker sends ../../appsettings.json.
✅ Correct — always generate a new UUID-based filename; store original name in database only.
Mistake 2 — Loading entire file into MemoryStream before storage (OOM on large files)
❌ Wrong — await file.CopyToAsync(ms); 50MB file = 50MB heap allocation per upload.
✅ Correct — stream directly: await storageService.UploadAsync(..., file.OpenReadStream(), ...).