ASP.NET Core File Upload — IFormFile, Validation and Storage

📋 Table of Contents
  1. File Upload API
  2. Common Mistakes

File uploads require careful attention to security — malicious files can execute server-side code or consume excessive storage. The ASP.NET Core file upload pipeline validates input at multiple layers: file extension (weak — easily spoofed), magic bytes (the actual file signature — stronger), and size limits (prevents disk exhaustion). Images are resized server-side to standardise storage and prevent enormous originals from being stored. The final URL is returned to the client for storage in the database.

File Upload API

// ── Program.cs — configure upload limits ─────────────────────────────────
builder.Services.Configure<FormOptions>(opts =>
{
    opts.MultipartBodyLengthLimit = 10 * 1024 * 1024;  // 10MB max
});
builder.WebHost.ConfigureKestrel(opts =>
{
    opts.Limits.MaxRequestBodySize = 10 * 1024 * 1024;  // 10MB
});

// ── UploadsController ─────────────────────────────────────────────────────
[ApiController, Route("api/uploads"), Authorize]
public class UploadsController : ControllerBase
{
    private readonly IBlobStorageService _storage;
    private readonly ILogger<UploadsController> _logger;

    private static readonly HashSet<string> AllowedMimeTypes =
        ["image/jpeg", "image/png", "image/webp", "image/gif"];

    private static readonly Dictionary<string, byte[]> MagicBytes = new()
    {
        ["image/jpeg"] = [0xFF, 0xD8, 0xFF],
        ["image/png"]  = [0x89, 0x50, 0x4E, 0x47],
        ["image/webp"] = [0x52, 0x49, 0x46, 0x46],  // RIFF header
        ["image/gif"]  = [0x47, 0x49, 0x46],          // GIF
    };

    [HttpPost("images")]
    [RequestSizeLimit(10 * 1024 * 1024)]
    public async Task<ActionResult<UploadResult>> UploadImage(
        IFormFile file, CancellationToken ct)
    {
        // ── Validate file size ────────────────────────────────────────────
        if (file.Length == 0)        return BadRequest("File is empty.");
        if (file.Length > 5_000_000) return BadRequest("File must be under 5MB.");

        // ── Validate content type ─────────────────────────────────────────
        if (!AllowedMimeTypes.Contains(file.ContentType.ToLower()))
            return BadRequest("Only JPEG, PNG, WebP and GIF images are allowed.");

        // ── Validate magic bytes (prevent extension spoofing) ─────────────
        using var stream = file.OpenReadStream();
        var header       = new byte[8];
        await stream.ReadExactlyAsync(header, 0, 8, ct);
        stream.Position  = 0;  // reset for later use

        var mimeOk = MagicBytes.Any(kvp =>
            header.Take(kvp.Value.Length).SequenceEqual(kvp.Value) &&
            kvp.Key == file.ContentType.ToLower());

        if (!mimeOk)
            return BadRequest("File content does not match the declared type.");

        // ── Resize with ImageSharp ────────────────────────────────────────
        // Install: dotnet add package SixLabors.ImageSharp
        using var image   = await Image.LoadAsync(stream, ct);
        image.Mutate(x => x.Resize(new ResizeOptions {
            Size = new Size(1200, 0),     // max width 1200px, height auto
            Mode = ResizeMode.Max,         // only shrink, never enlarge
        }));

        using var output   = new MemoryStream();
        await image.SaveAsWebpAsync(output, new WebpEncoder
            { Quality = 82 }, ct);         // compress to WebP quality 82
        output.Position    = 0;

        // ── Store the file ────────────────────────────────────────────────
        var fileName  = $"{Guid.NewGuid():N}.webp";
        var publicUrl = await _storage.UploadAsync(
            container: "images",
            fileName:  fileName,
            content:   output,
            mimeType:  "image/webp",
            ct:        ct);

        return Ok(new UploadResult(publicUrl, fileName));
    }
}

public record UploadResult(string Url, string FileName);
Note: Checking magic bytes (the first few bytes of the file content) is significantly stronger than checking the file extension or Content-Type header — both of which are trivially spoofed by renaming a file or changing the request. A JPEG always starts with FF D8 FF; a PNG with 89 50 4E 47. An attacker cannot fake these bytes and still have a valid image that browsers render. However, magic byte validation alone is not sufficient against all attacks — pair it with server-side processing (ImageSharp decoding) which will throw an exception if the content is not a valid image.
Tip: Convert all uploaded images to WebP format server-side. WebP files are 25-35% smaller than equivalent-quality JPEG or PNG at the same visual quality. Modern browsers (Chrome, Firefox, Safari, Edge) all support WebP. Converting at upload time means the storage cost is lower, CDN bandwidth is reduced, and pages load faster — all for zero client-side effort. Use ImageSharp‘s WebpEncoder with quality 80-85 for a good balance between file size and visual quality.
Warning: Never store uploaded files in a location that is web-accessible by default in the same path they were uploaded with their original names. An attacker who uploads a malicious file named exploit.aspx (or renames it to match a valid image extension but containing server-side script) should not be able to execute it by navigating to the file URL. Store files with GUID names (not original filenames), use a separate blob storage service (Azure Blob) or serve through a dedicated endpoint that explicitly sets the correct content type.

Common Mistakes

Mistake 1 — Trusting file extension or Content-Type header (easily spoofed)

❌ Wrong — if (file.ContentType == "image/jpeg"); attacker renames malware.exe to photo.jpg and changes Content-Type.

✅ Correct — check magic bytes from the file content; also process the image through ImageSharp (invalid images throw on decode).

Mistake 2 — Storing files with original filenames (path traversal and overwrite attacks)

❌ Wrong — saving as file.FileName; attacker uploads file named ../../web.config; overwrites server config.

✅ Correct — always generate a new GUID filename: $"{Guid.NewGuid():N}.webp"; ignore the original filename entirely.

🧠 Test Yourself

An attacker uploads a file with Content-Type: image/jpeg and a .jpg extension, but the file content is a PHP shell script. Which validation catches this?