Content Types and File Uploads

Sending files and streams, accepting a multipart upload, setting size limits, and why a client's file name proves nothing.

SyntaxTypedResults.File(Stream fileStream, string? contentType, string? fileDownloadName)

Not every response is JSON and not every request body is either. A report is a PDF, an avatar is a PNG, and a browser upload arrives as multipart/form-data: the body is cut into parts, each with its own name, file name and content type.

Sending a file

TypedResults.File takes a byte[] or a Stream, a content type, and optionally a download name. It sets Content-Type, sets Content-Disposition when you gave a name, and handles conditional and range requests when you pass an entity tag or enable range processing. TypedResults.Bytes and TypedResults.Stream are documented aliases for the same two methods, so pick whichever name reads better. A stream is the right choice when the content is large or comes from elsewhere, because nothing has to be held in memory first.

Accepting an upload

An IFormFile parameter binds to the part whose form field name matches the parameter name. It gives you FileName, Length, ContentType, OpenReadStream() and CopyToAsync. Two limits apply before your code runs: Kestrel's MaxRequestBodySize, 30,000,000 bytes by default, and FormOptions.MultipartBodyLengthLimit, 134,217,728 bytes. Check Length yourself as well and reject early.

The extension proves nothing

FileName and ContentType are strings the client chose. A file called cat.png can hold anything. Store under a name the server generates, keep an allow-list of extensions, verify the leading bytes against the format you expect, and never build a path by joining the client's name onto a directory.

Example

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Download. The third argument is the name the browser saves it under.
app.MapGet("/receipt", () =>
{
    byte[] bytes = "item,price\nbook,9.99\n"u8.ToArray();
    return TypedResults.File(bytes, "text/csv", "receipt.csv");
});

// Upload. Binds to the multipart part whose field name is "file".
app.MapPost("/uploads", async Task<Results<Ok<Stored>, ProblemHttpResult>> (
        IFormFile file, CancellationToken cancellationToken) =>
    {
        if (file.Length > 2 * 1024 * 1024)
        {
            return TypedResults.Problem(
                detail: "Files must be 2 MB or smaller.",
                statusCode: StatusCodes.Status413PayloadTooLarge);
        }

        // file.FileName came from the client, so the server names the file on disk.
        string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
        await using (FileStream target = File.Create(path))
        {
            await file.CopyToAsync(target, cancellationToken);
        }

        return TypedResults.Ok(new Stored(Path.GetFileName(path), file.Length));
    })
    .DisableAntiforgery();   // the caller is a script, not a browser form

app.Run();

record Stored(string Name, long Bytes);

When to use it

  • Let a user download a generated PDF invoice with a sensible file name.
  • Accept a profile photo from a browser form and store it under a server-generated name.
  • Stream a large export straight from blob storage to the client without buffering it.
  • Reject a 500 MB upload with a clear status code instead of filling the disk.

More examples

Streaming without buffering, and the aliases

Use the Stream overload whenever the content already exists somewhere else, since the alternative is reading the whole file into a byte array first. The named arguments in the last endpoint are not decoration: the byte[] overload takes (bytes, contentType, fileDownloadName, enableRangeProcessing, lastModified, entityTag) while the Stream overload takes (stream, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing), and passing them positionally across the two is an easy way to turn range processing off by accident.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Nothing is held in memory: the stream is read as the response is written.
// Do not dispose it here; the handler returns before the body is sent.
app.MapGet("/export", () =>
{
    Stream stream = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "export.csv"));
    return TypedResults.File(stream, "text/csv", "export.csv");
});

// Stream and Bytes are aliases for the same two File overloads.
// These three endpoints produce byte-for-byte identical responses.
app.MapGet("/a", () => TypedResults.File("hello"u8.ToArray(), "text/plain"));
app.MapGet("/b", () => TypedResults.Bytes("hello"u8.ToArray(), "text/plain"));
app.MapGet("/c", () => TypedResults.Stream(new MemoryStream("hello"u8.ToArray()), "text/plain"));

// Range and conditional requests: a video player can seek, and a repeat
// request whose If-Modified-Since matches lastModified gets 304 instead of the
// body. A 304 from If-None-Match would need an entityTag, which is null here.
app.MapGet("/video", () =>
{
    Stream video = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "clip.mp4"));
    return TypedResults.File(
        video,
        contentType: "video/mp4",
        fileDownloadName: null,
        lastModified: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
        entityTag: null,
        enableRangeProcessing: true);
});

app.Run();

Checking what was actually uploaded

This is the check that stops a renamed executable or a script from being stored as an image. The extension test is cheap and rejects obvious nonsense; the signature test is what actually decides. Run it before writing anything to disk, and keep uploads in a directory that the web server does not serve and the operating system does not allow to execute.

Example · csharp
// Called from an upload handler before anything is stored.
public static class UploadChecks
{
    private static readonly string[] AllowedExtensions = [".png", ".jpg", ".jpeg"];

    // The first bytes of a file identify its real format.
    private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
    private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF];

    public static async Task<bool> IsAllowedImageAsync(
        IFormFile file,
        CancellationToken cancellationToken)
    {
        // The extension is only a hint, but a wrong one is grounds to stop early.
        string extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (!AllowedExtensions.Contains(extension))
        {
            return false;
        }

        byte[] header = new byte[8];
        await using Stream content = file.OpenReadStream();
        int read = await content.ReadAtLeastAsync(header, header.Length,
            throwOnEndOfStream: false, cancellationToken);

        return header.AsSpan(0, read).StartsWith(PngSignature)
            || header.AsSpan(0, read).StartsWith(JpegSignature);
    }
}

Raising the limit for one endpoint

Two separate limits exist and both can reject a request, so raising one alone does nothing. Set the app-wide values low, near the largest upload you actually expect, and lift the limit on the one endpoint that needs more. IsReadOnly is true once the body has started being read, which is why the feature is set as the first thing the handler does.

Example · csharp
using Microsoft.AspNetCore.Http.Features;

var builder = WebApplication.CreateBuilder(args);

// Applies to the whole app: the server rejects a larger body before any handler runs.
builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;   // default is 30,000,000 bytes
});

// The multipart parser has its own limit, 134,217,728 bytes by default.
builder.Services.Configure<FormOptions>(options =>
{
    options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
});

var app = builder.Build();

// One endpoint may raise its own limit, but only before the body is read.
app.MapPost("/uploads/large", async (HttpContext context) =>
{
    IHttpMaxRequestBodySizeFeature? limit =
        context.Features.Get<IHttpMaxRequestBodySizeFeature>();

    if (limit is { IsReadOnly: false })
    {
        limit.MaxRequestBodySize = 100 * 1024 * 1024;
    }

    IFormCollection form = await context.Request.ReadFormAsync();
    return TypedResults.Ok(form.Files.Count);
}).DisableAntiforgery();

app.Run();

Discussion

  • Be the first to comment on this lesson.