Skip to main content

08 - File Upload

Overview

File upload is a common requirement in web applications — profile pictures, documents, attachments, etc. ASP.NET Core handles file uploads through the IFormFile interface, which represents a file sent with an HTTP request.

Key concepts:

  • IFormFile — abstraction over the uploaded file (access to stream, filename, content type, length)
  • multipart/form-data — the required form encoding type for file uploads (default application/x-www-form-urlencoded cannot transmit binary data)
  • Files are buffered in memory/temp storage by the framework before your action method runs

Controller Setup

Inject IWebHostEnvironment to get access to path information (WebRootPath points to wwwroot):

public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
private readonly IWebHostEnvironment _env;

public HomeController(ILogger<HomeController> logger, IWebHostEnvironment env)
{
_logger = logger;
_env = env;
}

The GET action simply returns the upload form:

public IActionResult Upload()
{
return View();
}

ViewModel

The ViewModel wraps the IFormFile. Use [Required] so model validation catches missing files:

using System.ComponentModel.DataAnnotations;

namespace WebApp.Areas.Admin.ViewModels;

public class FileUploadViewModel
{
[Required]
public IFormFile File { get; set; } = default!;
}

View — Upload Form

The form must have enctype="multipart/form-data" — without it, the browser sends only the filename as text, not the actual file content. The accept attribute provides client-side filtering (but is not a security measure — server must always validate).

@model WebApp.Areas.Admin.ViewModels.FileUploadViewModel

<h1>Upload image file</h1>

<hr/>
<div class="row">
<div class="col-md-4">
<form method="post" asp-action="Upload" enctype="multipart/form-data">

<div asp-validation-summary="All" class="text-danger"></div>

<div class="form-group">
<label asp-for="File" class="control-label"></label>
<input asp-for="File" class="form-control" accept="image/*"/>
<span asp-validation-for="File" class="text-danger"></span>
</div>

<div class="form-group">
<input type="submit" value="Upload" class="btn btn-primary"/>
</div>
</form>
</div>
</div>

POST Action — Handling the Upload

[HttpPost]
public async Task<IActionResult> Upload(FileUploadViewModel vm)
{
var allowedExtensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif" };

if (ModelState.IsValid)
{
var extension = Path.GetExtension(vm.File.FileName).ToLowerInvariant();

if (vm.File.Length > 0 && allowedExtensions.Contains(extension))
{
var uploadDir = Path.Combine(_env.WebRootPath, "uploads");

// ensure the upload directory exists
Directory.CreateDirectory(uploadDir);

// generate a safe filename — GUID avoids collisions and path traversal
var filename = Guid.NewGuid() + extension;

var filePath = Path.Combine(uploadDir, filename);

await using (var stream = System.IO.File.Create(filePath))
{
await vm.File.CopyToAsync(stream);
}

// TODO: save file metadata to database (original name, stored name, size, content type, etc.)
return RedirectToAction(nameof(ListFiles));
}

ModelState.AddModelError(
nameof(FileUploadViewModel.File),
"This is not an image file! " + vm.File.FileName);
}

return View(vm);
}

Key decisions in this code:

  • Path.Combine() instead of string concatenation — handles path separators correctly across OS
  • GUID-only filename — the client-provided filename is untrusted; using only a GUID + extension prevents path traversal attacks and filename collisions
  • Directory.CreateDirectory() — idempotent, creates the directory if missing, no-op if it exists
  • Extension to lowercase — file extensions are case-insensitive; .PNG and .png should both be accepted

Security Considerations

File upload is a significant attack surface. Defense in depth — apply multiple layers of validation:

Extension Validation (shown above)

  • Allow-list approach — only permit known-good extensions
  • Never use a deny-list (too easy to miss dangerous extensions)
  • Always normalize to lowercase before comparing

Content Validation (magic bytes)

Extension checking alone is insufficient — a user can rename malware.exe to malware.png. Check the file's actual content by inspecting the first few bytes (magic bytes / file signature):

private static readonly Dictionary<string, byte[]> FileSignatures = new()
{
{ ".png", new byte[] { 0x89, 0x50, 0x4E, 0x47 } }, // ‰PNG
{ ".jpg", new byte[] { 0xFF, 0xD8, 0xFF } },
{ ".gif", new byte[] { 0x47, 0x49, 0x46 } }, // GIF
{ ".bmp", new byte[] { 0x42, 0x4D } }, // BM
};

private bool HasValidSignature(IFormFile file, string extension)
{
if (!FileSignatures.TryGetValue(extension, out var signature))
return false;

using var reader = new BinaryReader(file.OpenReadStream());
var headerBytes = reader.ReadBytes(signature.Length);
return headerBytes.SequenceEqual(signature);
}

File Size Limits

By default, ASP.NET Core limits request size. Configure explicitly:

// on the action or controller
[RequestSizeLimit(10 * 1024 * 1024)] // 10 MB
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
[HttpPost]
public async Task<IActionResult> Upload(FileUploadViewModel vm)

Or globally in Program.cs:

builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
});

Also validate in your action: if (vm.File.Length > maxFileSize) { ... }

Path Traversal

A malicious filename like ../../../etc/passwd could write outside the upload directory. Defenses:

  • Path.GetFileName() — strips directory components (used in the example above)
  • GUID-only filenames — strongest defense, client filename is never used in the path
  • Verify the resolved path starts with the intended upload directory

MIME Type (ContentType)

vm.File.ContentType comes from the client and can be spoofed. Useful as a first check, but never rely on it alone — always combine with extension and content validation.

Architectural Considerations

The example above puts everything in the controller — acceptable for learning, but in a layered architecture this violates separation of concerns. File handling is business logic, not controller responsibility.

Where File Handling Belongs

In a layered architecture (see lecture 24 — BLL):

Controller → IFileService (BLL) → IFileRepository (DAL) + filesystem/cloud

A service interface makes the storage mechanism swappable:

public interface IFileService
{
Task<Guid> SaveFileAsync(IFormFile file);
Task<(Stream Stream, string ContentType, string FileName)?> GetFileAsync(Guid id);
Task DeleteFileAsync(Guid id);
}

The controller becomes thin:

[HttpPost]
public async Task<IActionResult> Upload(FileUploadViewModel vm)
{
if (!ModelState.IsValid) return View(vm);

var fileId = await _fileService.SaveFileAsync(vm.File);
return RedirectToAction(nameof(ListFiles));
}

Storage Strategies

StrategyWhen to UseTrade-offs
wwwroot/uploads/Public files (images on pages), simple appsDirect URL access, no auth control, lost on redeploy if not volume-mounted
Outside wwwrootPrivate files requiring auth checksMust serve through controller action, more control
Database (BLOB)Small files, transactional consistency neededIncreases DB size, backed up with data, simpler deployment
Cloud storage (Azure Blob, S3)Production apps, scalability neededBest for production, CDN integration, costs money

In real projects, the service abstraction lets you start with local filesystem and switch to cloud storage later without changing controllers or business logic.

File Metadata Entity

Store metadata about uploaded files in the database — the actual file can live on disk or in cloud storage:

public class StoredFile
{
public Guid Id { get; set; }
public string OriginalFileName { get; set; } = default!;
public string StoredFileName { get; set; } = default!;
public string ContentType { get; set; } = default!;
public long FileSize { get; set; }
public DateTime UploadedAt { get; set; }
public string UploadedBy { get; set; } = default!;
}

This enables listing files, associating files with other entities (e.g., a product image), and serving files with their original names.

Serving / Downloading Files

The upload example references ListFiles — here is how to serve files back:

public IActionResult ListFiles()
{
var uploadDir = Path.Combine(_env.WebRootPath, "uploads");

// in a real app, query from database instead of scanning the filesystem
var files = Directory.Exists(uploadDir)
? Directory.GetFiles(uploadDir).Select(Path.GetFileName).ToList()
: new List<string?>();

return View(files);
}

For files stored outside wwwroot (private files), serve through a controller action:

public async Task<IActionResult> Download(Guid id)
{
var fileInfo = await _fileService.GetFileAsync(id);
if (fileInfo == null) return NotFound();

var (stream, contentType, fileName) = fileInfo.Value;
return File(stream, contentType, fileName);
}

ASP.NET Core provides several FileResult types:

  • File(byte[], contentType) — from byte array (loads entire file into memory)
  • File(stream, contentType) — from stream (better for large files)
  • PhysicalFile(path, contentType) — from absolute file path on disk
  • The optional fileDownloadName parameter sets Content-Disposition: attachment — browser will download instead of display

Multiple File Upload

For uploading multiple files at once, use List<IFormFile>:

public class MultiFileUploadViewModel
{
[Required]
public List<IFormFile> Files { get; set; } = default!;
}
<input asp-for="Files" class="form-control" accept="image/*" multiple/>

In the POST action, iterate and validate each file individually.

What to Know for Code Defense

Be prepared to explain:

  1. Why multipart/form-data? — default URL encoding cannot transmit binary data
  2. Why GUID filenames? — prevents path traversal, collisions, and information leakage from original filenames
  3. Why not trust the extension alone? — extensions can be renamed; check magic bytes for defense in depth
  4. Why not trust ContentType? — it comes from the client and is trivially spoofable
  5. Why Path.Combine() over string concat? — handles OS-specific path separators correctly
  6. Where should file handling live in a layered architecture? — in the service/BLL layer, not the controller
  7. Why use a service abstraction (IFileService)? — makes storage swappable (local → cloud) without changing controllers
  8. What are the trade-offs between storage strategies? — filesystem vs database vs cloud (see table above)
  9. How do you limit file size?[RequestSizeLimit], Kestrel config, and manual check in the action
  10. How do you serve private files? — through a controller action using File() return type, not direct URL