Preventing Path Traversal Attacks with File Uploads in ASP.NET Core Minimal APIs

Never use raw IFormFile.FileName when constructing server-side paths; instead generate random filenames, validate content types, and enforce strict path boundaries to block directory traversal sequences like ../.

File uploads in ASP.NET Core minimal APIs streamline development but introduce severe security risks when user-supplied filenames are used to construct file-system paths. The dotnet/skills repository provides production-ready guidance demonstrating how attackers exploit directory traversal sequences to write files outside intended directories, and how to implement robust defenses. By following the patterns established in the minimal-api-file-upload skill, you can eliminate path traversal vulnerabilities while maintaining clean, endpoint-based code.

Why Path Traversal Is a Critical Risk in Minimal APIs

Minimal APIs remove the ceremony of traditional MVC controllers, but this brevity often leads developers to overlook input validation. When handling IFormFile uploads, using the raw FileName property directly in Path.Combine allows attackers to embed sequences like ../../../etc/passwd, causing the application to write sensitive system files. The dotnet/skills repository explicitly warns against this anti-pattern in plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md at line 158, emphasizing that the user-provided filename should never touch the file system.

Core Defense Strategies

Generate Random Filenames Instead of Trusting User Input

The primary defense against path traversal is eliminating attacker-controlled path components entirely. According to the guidance in SKILL.md (line 158), you should generate a new random filename—such as a GUID—and only derive the extension after validating the content type.

// Derive safe extension from detection, then generate a random name
var extension = detected == "image/jpeg" ? ".jpg" : ".png";
var safeName = $"{Guid.NewGuid()}{extension}";
var uploadPath = Path.Combine("uploads", safeName);

This approach ensures that even if an attacker uploads a file named ../../../malicious.exe, the dangerous path segments never reach the operating system.

Validate Content Types and Magic Bytes

Checking file extensions alone is insufficient; attackers can disguise payloads by changing extensions. The skill file at lines 121-152 mandates validating both MIME types and magic bytes (file signatures) to verify the file's true format.

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

// Magic-byte check
await using var stream = file.OpenReadStream();
var header = new byte[8];
var read = await stream.ReadAsync(header, 0, header.Length);
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;

Verify that the detected content matches the declared MIME type before writing any bytes to disk.

Sanitize Path Components with Root Directory Checks

When you must incorporate user-provided directory names (not filenames), implement defensive path resolution. The LocalSessionFsHandler.cs file at line 35 demonstrates a security check that resolves the full path and verifies it remains within a trusted root directory.

string SafeCombine(string baseDir, string userPath)
{
    var fullPath = Path.GetFullPath(Path.Combine(baseDir, userPath));
    if (!fullPath.StartsWith(Path.GetFullPath(baseDir), StringComparison.Ordinal))
        throw new UnauthorizedAccessException($"Path traversal blocked: {userPath}");
    return fullPath;
}

This pattern throws UnauthorizedAccessException immediately if the resolved path escapes the intended upload folder.

Enforce Request Size Limits

Prevent denial-of-service attacks by configuring limits at both the Kestrel server and form parser levels. The SKILL.md at lines 55-68 recommends setting MaxRequestBodySize and MultipartBodyLengthLimit to appropriate values, such as 10 MB for image uploads.

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 10 * 1024 * 1024);
builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = 10 * 1024 * 1024);

Handle Anti-Forgery Tokens Correctly

By default, ASP.NET Core validates antiforgery tokens on form-bound endpoints. For API-only uploads using JWT authentication, you must explicitly disable this check to avoid 400 errors. The skill file at lines 96-106 shows the correct pattern:

app.MapPost("/api/upload", (IFormFile file) => Results.Ok(file.FileName))
   .DisableAntiforgery();   // safe when using JWT or no auth

Only disable antiforgery when you have alternative authentication mechanisms in place.

Complete Implementation Example

Combine these safeguards into a single minimal API endpoint that handles uploads securely:

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

    // 2. Validate magic bytes
    await using var stream = file.OpenReadStream();
    var header = new byte[8];
    var read = await stream.ReadAsync(header, 0, header.Length);
    if (read < 4) return Results.BadRequest("File 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;
    var detected = isJpeg ? "image/jpeg" : isPng ? "image/png" : null;
    if (detected is null) return Results.BadRequest("Invalid image format");
    if (!string.Equals(detected, file.ContentType, StringComparison.OrdinalIgnoreCase))
        return Results.BadRequest("Content-Type mismatch");

    // 3. Generate safe filename
    var extension = detected == "image/jpeg" ? ".jpg" : ".png";
    var safeName = $"{Guid.NewGuid()}{extension}";
    var uploadPath = Path.Combine("uploads", safeName);
    Directory.CreateDirectory("uploads");

    // 4. Optional: Verify path is within upload directory
    if (!Path.GetFullPath(uploadPath).StartsWith(Path.GetFullPath("uploads"), StringComparison.Ordinal))
        return Results.BadRequest("Invalid path");

    // 5. Write file
    stream.Position = 0;
    await using var outFile = File.Create(uploadPath);
    await stream.CopyToAsync(outFile);

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

This implementation follows the exact patterns recommended in the dotnet/skills repository, preventing path traversal through randomized names, content validation, and path verification.

Summary

  • Never trust IFormFile.FileName for file system operations; generate GUID-based filenames and derive extensions only after validation.
  • Validate both MIME types and magic bytes to ensure files match their claimed formats, as detailed in SKILL.md lines 121-152.
  • Implement root-directory checks using Path.GetFullPath comparisons to block traversal attempts, mirroring the logic in LocalSessionFsHandler.cs line 35.
  • Configure size limits in both Kestrel (MaxRequestBodySize) and FormOptions (MultipartBodyLengthLimit) to prevent DoS attacks.
  • Manage antiforgery tokens appropriately—disable only for API endpoints with alternative authentication, as shown in SKILL.md lines 96-106.

Frequently Asked Questions

Can I use the original filename if I sanitize it?

While sanitization is possible, the dotnet/skills repository strongly recommends against it. According to the guidance at line 158 in minimal-api-file-upload/SKILL.md, generating a completely new random filename (like a GUID) eliminates all path traversal risks. If you must preserve the original name, apply rigorous whitelist-based sanitization and still verify the final path resolves within your upload directory.

How do I validate file content beyond extensions?

Inspect the file's magic bytes—the specific byte sequences that identify file types. For example, JPEG files start with 0xFF 0xD8 0xFF and PNG files with 0x89 0x50 0x4E 0x47. Read the first 8 bytes of the stream and compare them against known signatures, then verify they match the declared ContentType. This prevents attackers from uploading executable code disguised as images.

What size limits should I set for file uploads?

Set limits based on your specific use case and available memory. The examples in SKILL.md lines 55-68 demonstrate 10 MB limits using MaxRequestBodySize for Kestrel and MultipartBodyLengthLimit for form parsing. For production applications, consider smaller limits (1-5 MB) for images and implement chunked uploading or streaming for larger files to avoid memory exhaustion.

When should I disable antiforgery in minimal APIs?

Disable antiforgery only for API endpoints that do not use cookie-based authentication. As shown in SKILL.md lines 96-106, calling .DisableAntiforgery() is appropriate when your minimal API uses JWT tokens, API keys, or other stateless authentication. For form-based uploads in browser applications, keep antiforgery enabled to prevent cross-site request forgery attacks.

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 →