Validating Content-Type Headers with Magic Bytes for File Upload Security in .NET Minimal APIs

Validate uploaded files by inspecting their magic bytes (file signatures) to ensure the actual content matches the declared Content-Type header, preventing MIME-type spoofing attacks in ASP.NET Core Minimal APIs.

The dotnet/skills repository provides a production-ready pattern for secure file uploads that combines header validation with byte-level inspection. Located in the dotnet-aspnetcore plugin under plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md, this implementation demonstrates how to validate Content-Type headers against magic bytes to mitigate spoofing risks in .NET 8+ Minimal APIs.

The Risk of Trusting Content-Type Headers Alone

Attackers can easily spoof MIME types by setting a malicious file's Content-Type header to image/jpeg while the actual payload contains executable code. Relying solely on the IFormFile.ContentType property exposes your application to malware uploads and remote code execution vulnerabilities. The dotnet/skills implementation addresses this by requiring cryptographic verification of the file's actual structure through magic byte inspection.

Defense-in-Depth Validation Layers

The skill implements a layered security architecture as defined in plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md. Each layer addresses a specific attack vector:

Enforcing Request Size Limits

Prevent denial-of-service attacks by configuring both Kestrel and FormOptions limits before processing uploads.

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

builder.Services.Configure<FormOptions>(opts =>
{
    opts.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MiB
    opts.ValueLengthLimit = 1 * 1024 * 1024;        // 1 MiB for form fields
});

Validating Declared Content Types

Check the file.ContentType property against an explicit allowlist before attempting to process the stream.

var allowedTypes = new[] { "image/jpeg", "image/png" };
if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase))
    return Results.BadRequest("File type not allowed");

Verifying Magic Bytes (File Signatures)

Read the first 8 bytes of the stream to detect the actual file format. JPEG files start with the hex sequence FF D8 FF, while PNG files begin with 89 50 4E 47.

using var stream = file.OpenReadStream();
var header = new byte[8];
var bytesRead = await stream.ReadAsync(header, 0, header.Length);
if (bytesRead < 4) return Results.BadRequest("File content is too short");

bool isJpeg = header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF;
bool isPng  = header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47;
string? detected = isJpeg ? "image/jpeg" : isPng ? "image/png" : null;

if (detected is null) return Results.BadRequest("Unsupported file format");

// Ensure declared and detected types match
if (!string.Equals(file.ContentType, detected, StringComparison.OrdinalIgnoreCase))
    return Results.BadRequest("Declared Content-Type does not match file content");

Generating Safe Filenames

Eliminate path-traversal vulnerabilities by using Guid.NewGuid() for filenames and deriving extensions from the verified content type rather than the original upload name.

var extension = detected == "image/jpeg" ? ".jpg" : ".png";
var safeFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine("uploads", safeFileName);

Complete Implementation Example

The following endpoint from SKILL.md demonstrates the complete validation pipeline, combining allowlist checking, magic byte verification, and safe storage:

app.MapPost("/upload", async (IFormFile file) =>
{
    // Allowed MIME types
    var allowedTypes = new[] { "image/jpeg", "image/png" };
    if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase))
        return Results.BadRequest("File type not allowed");

    // Read first bytes to detect real format
    using var stream = file.OpenReadStream();
    var header = new byte[8];
    var bytesRead = await stream.ReadAsync(header, 0, header.Length);
    if (bytesRead < 4) return Results.BadRequest("File content is too short");

    bool isJpeg = header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF;
    bool isPng  = header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47;
    string? detected = isJpeg ? "image/jpeg" : isPng ? "image/png" : null;
    if (detected is null) return Results.BadRequest("Unsupported file format");

    // Ensure declared and detected types match
    if (!string.Equals(file.ContentType, detected, StringComparison.OrdinalIgnoreCase))
        return Results.BadRequest("Declared Content-Type does not match file content");

    // Generate a safe filename
    var extension = detected == "image/jpeg" ? ".jpg" : ".png";
    var safeFileName = $"{Guid.NewGuid()}{extension}";
    var filePath = Path.Combine("uploads", safeFileName);
    Directory.CreateDirectory("uploads");
    stream.Position = 0;
    using var outStream = File.Create(filePath);
    await stream.CopyToAsync(outStream);

    return Results.Ok(new { FileName = safeFileName, file.Length });
});

Streaming Large Files Without Buffering

For memory-efficient processing of large uploads, the skill demonstrates a low-level MultipartReader implementation that parses the multipart body and streams each section directly to disk without buffering the entire payload in memory. This approach is defined in plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md and is essential for high-traffic scenarios.

app.MapPost("/upload-stream",
    [DisableRequestSizeLimit] async (HttpContext ctx) =>
    {
        var contentType = ctx.Request.ContentType;
        if (contentType == null) return Results.BadRequest("Missing Content-Type");

        if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType))
            return Results.BadRequest("Invalid Content-Type");

        var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
        if (string.IsNullOrWhiteSpace(boundary)) return Results.BadRequest("Not a multipart request");

        var reader = new MultipartReader(boundary, ctx.Request.Body);
        while (await reader.ReadNextSectionAsync() is { } section)
        {
            if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var cd))
                continue;

            if (cd.DispositionType.Equals("form-data") &&
                !string.IsNullOrEmpty(cd.FileName.Value))
            {
                var safeFile = $"{Guid.NewGuid()}";
                Directory.CreateDirectory("uploads");
                using var fs = File.Create(Path.Combine("uploads", safeFile));
                await section.Body.CopyToAsync(fs);
            }
        }

        return Results.Ok("Uploaded");
    }).DisableAntiforgery();

Configuration Best Practices

The dotnet/skills repository includes validation scenarios in plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/eval.yaml that verify the solution checks actual file content rather than just headers. The CI pipeline uses eng/skill-validator/src/Program.cs to execute these validation tests automatically.

For production deployments, enable anti-forgery protection on form-based uploads:

builder.Services.AddAntiforgery();
app.UseAntiforgery();

API-only endpoints can opt-out using .DisableAntiforgery() when implementing token-based authentication instead.

Summary

  • Validate Content-Type headers against an explicit allowlist before processing uploads
  • Verify magic bytes by reading the first 8 bytes of the stream to confirm the actual file format matches the declared MIME type
  • Generate safe filenames using Guid.NewGuid() with extensions derived from verified content types, never from the original filename
  • Configure size limits at both the Kestrel (MaxRequestBodySize) and FormOptions (MultipartBodyLengthLimit) layers to prevent DoS attacks
  • Stream large files using MultipartReader to avoid memory exhaustion on high-traffic endpoints

Frequently Asked Questions

What are magic bytes and why are they important for file upload security?

Magic bytes are hexadecimal signatures at the beginning of files that identify the actual format regardless of the file extension or Content-Type header. They are crucial because HTTP headers can be easily spoofed by attackers attempting to upload executable files disguised as images. By checking for JPEG (FF D8 FF) or PNG (89 50 4E 47) signatures, you verify the file's true nature before processing.

How does the dotnet/skills implementation prevent path traversal attacks?

The implementation eliminates path traversal vulnerabilities by completely disregarding the original filename provided by the client. Instead, it generates safe filenames using Guid.NewGuid() and appends extensions (.jpg or .png) only after verifying the content type through magic byte inspection. This ensures malicious filenames like ../../../etc/passwd cannot influence the storage location.

What is the difference between IFormFile and MultipartReader approaches?

IFormFile buffers the entire upload in memory or on disk by default, making it suitable for small files but risky for large uploads or high-traffic scenarios. MultipartReader processes the HTTP request stream directly without buffering, allowing you to handle gigabyte-scale uploads with minimal memory footprint. The dotnet/skills repository provides examples of both approaches in SKILL.md to accommodate different scalability requirements.

Can this validation be bypassed by polyglot files?

While magic byte validation catches simple MIME-type spoofing, sophisticated polyglot files (files valid in multiple formats) may require additional validation. The dotnet/skills pattern mitigates this by combining byte verification with size limits and safe filename generation. For highly sensitive applications, consider adding dimension verification (for images) or sandboxed processing as defense-in-depth layers beyond the magic byte checks.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →