mcpField
M
minimal-api-file-upload

minimal-api-file-upload

github.com/dotnet开发5.8k2.3k 次安装1 天前更新
代码仓库

关于 minimal-api-file-upload

面向 .NET 8+ ASP.NET Core Minimal APIs 文件上传端点的技能,涵盖参数绑定、大小限制、防伪验证、文件校验和流式处理。

主要功能

  1. 01IFormFile 绑定说明
  2. 02分别配置请求与多部分大小限制
  3. 03防伪验证处理
  4. 04内容类型与文件签名校验
  5. 05MultipartReader 流式处理说明

适用场景

  1. 01为 Minimal API 添加上传端点
  2. 02校验上传文件类型
  3. 03避免完整缓冲地处理大文件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill minimal-api-file-upload

安装后在 Claude Code 中输入/minimal-api-file-upload确认已加载。

详细介绍

在 ASP.NET Core Minimal API 中实现文件上传

适用场景

  • 在 ASP.NET Core Minimal API(.NET 8+)中创建文件上传端点
  • 处理 IFormFile 或 IFormFileCollection 参数
  • 需要大小限制、内容类型验证或大文件流式处理

不适用场景

  • MVC 控制器 → [FromForm] IFormFile 可直接通过特性使用
  • 简单的 JSON 请求体 → 无需文件上传
  • 超大文件(> 1 GB)→ 改用 MultipartReader 流式处理

输入

输入必需描述
文件参数是IFormFile 或 IFormFileCollection
大小限制是最大文件大小/请求大小
允许的类型否内容类型或扩展名限制

工作流

步骤 1:重要 — 了解 Minimal API 中的 IFormFile 绑定
// In .NET 8+ minimal APIs, IFormFile binds automatically from multipart/form-data
// when it is the only complex parameter.
app.MapPost("/upload", (IFormFile file) => ...);

// CRITICAL: When you mix files with other form fields, use [FromForm] on all
// form-bound parameters (or group them into a single [FromForm] DTO).
app.MapPost("/upload-with-metadata",
    ([FromForm] IFormFile file, [FromForm] string description) =>
{
    return Results.Ok(new { file.FileName, Description = description });
});

// Multiple files: IFormFileCollection also binds automatically from multipart/form-data.
// You only need [FromForm] if you mix it with other form fields, as shown above.
app.MapPost("/upload-multiple", (IFormFileCollection files) =>
{
    return Results.Ok(files.Select(f => new { f.FileName, f.Length }));
});
步骤 2:重要 — 文件大小限制与请求大小限制是分开的
// CRITICAL: There are TWO different size limits and you need to configure BOTH

// 1. Request body size limit (Kestrel level) — default is 30MB
builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
});

// 2. Form options — multipart body length limit — default is 128MB
builder.Services.Configure<FormOptions>(options =>
{
    options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MB
    options.ValueLengthLimit = 1024 * 1024; // 1 MB for form values
    options.MultipartHeadersLengthLimit = 16384; // 16 KB for section headers
});

// COMMON MISTAKE: Only increasing Kestrel MaxRequestBodySize
// upload still fails because FormOptions.MultipartBodyLengthLimit is exceeded

// COMMON MISTAKE: Only increasing FormOptions
// upload fails with "Request body too large" from Kestrel before reaching form parsing

// CRITICAL: Per-endpoint override with RequestSizeLimit attribute
app.MapPost("/upload-large", [RequestSizeLimit(200_000_000)] (IFormFile file) =>
{
    return Results.Ok(new { file.FileName, file.Length });
});

// CRITICAL: To disable the limit entirely (for streaming):
app.MapPost("/upload-unlimited", [DisableRequestSizeLimit] async (HttpContext context) =>
{
    // Handle manually
});
步骤 3:重要 — .NET 8+ 会自动对表单上传进行防伪验证
// CRITICAL: In .NET 8+ with UseAntiforgery(), ALL form-bound endpoints
// automatically validate anti-forgery tokens, INCLUDING file uploads

builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseAntiforgery();

// This endpoint now REQUIRES an anti-forgery token:
app.MapPost("/upload", (IFormFile file) => Results.Ok(file.FileName));
// Without the token → 400 Bad Request

// CRITICAL: For API-only file uploads (no anti-forgery needed), opt out:
app.MapPost("/api/upload", (IFormFile file) => Results.Ok(file.FileName))
    .DisableAntiforgery();  // CRITICAL: Must explicitly opt out

// COMMON MISTAKE: Getting 400 errors on file uploads and not realizing
// it's because UseAntiforgery() is in the pipeline

// WARNING: DisableAntiforgery() is safe for unauthenticated endpoints and
// endpoints using JWT bearer authentication. However, for endpoints
// authenticated with cookies, disabling antiforgery removes CSRF protection
// and exposes the endpoint to cross-site request forgery attacks.
// For cookie-authenticated endpoints, include a valid antiforgery token instead.
步骤 4:重要 — 验证文件内容,而不只是扩展名
app.MapPost("/upload", async (IFormFile file) =>
{
    // CRITICAL: Check content type AND file signature (magic bytes)
    // NEVER trust file extension alone — it can be spoofed

    // Allow only JPEG/PNG by default. To support more (e.g., GIF),
    // add the MIME type here AND validate its magic bytes below.
    var allowedTypes = new[] { "image/jpeg", "image/png" };
    if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase))
        return Results.BadRequest("File type not allowed");

    // CRITICAL: Check magic bytes for file type verification
    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 or invalid");

    // JPEG: FF D8 FF
    // PNG: 89 50 4E 47
    var isJpeg = header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF;
    var isPng = header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47;

    // Determine the actual content type from magic bytes
    string? detectedContentType = isJpeg ? "image/jpeg" : isPng ? "image/png" : null;
    if (detectedContentType is null)
        return Results.BadRequest("File content is not a supported image format (only JPEG and PNG are allowed).");

    // Ensure the declared Content-Type matches what the magic bytes detected
    if (!string.Equals(file.ContentType, detectedContentType, StringComparison.OrdinalIgnoreCase))
        return Results.BadRequest("File content type does not match the declared ContentType header.");

    // CRITICAL: Never use the user-provided filename directly for the save path — it can
    // contain path traversal characters (e.g., "../../../etc/passwd").
    // Generate a safe filename; derive the extension from validated content, not user input.
    var extension = detectedContentType == "image/jpeg" ? ".jpg" : ".png";
    var safeFileName = $"{Guid.NewGuid()}{extension}";
    // NEVER: var path = Path.Combine("uploads", file.FileName);     // Path traversal!

    var filePath = Path.Combine("uploads", safeFileName);
    Directory.CreateDirectory("uploads");
    stream.Position = 0;
    using var fileStream = File.Create(filePath);
    await stream.CopyToAsync(fileStream);

    return Results.Ok(new { FileName = safeFileName, file.Length });
});
步骤 5:重要 — 不缓冲地流式处理大文件
// CRITICAL: IFormFile relies on multipart form parsing that buffers content in memory
// (up to a threshold) then spills to temp files on disk. For very large uploads,
// this overhead is unnecessary if you can process the data in chunks.
// Use MultipartReader to stream directly — e.g., to a final storage location —
// without buffering the entire file first.

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

    // Safely parse the Content-Type header to avoid FormatException from MediaTypeHeaderValue.Parse
    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, context.Request.Body);

    // CRITICAL: ReadNextSectionAsync returns null when there are no more sections
    while (await reader.ReadNextSectionAsync() is { } section)
    {
        // Parse Content-Disposition to identify file sections
        if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition))
            continue;

        if (contentDisposition.DispositionType.Equals("form-data")
            && !string.IsNullOrEmpty(contentDisposition.FileName.Value))
        {
            // Sanitize the user-provided filename to prevent path traversal
            var originalFileName = contentDisposition.FileName.Value ?? string.Empty;
            var sanitizedFileName = Path.GetFileName(originalFileName.Trim('"'));
            var safeFile = $"{Guid.NewGuid()}";

            // CRITICAL: Stream directly to disk — avoids buffering in memory
            Directory.CreateDirectory("uploads");
            using var fileStream = File.Create(Path.Combine("uploads", safeFile));
            await section.Body.CopyToAsync(fileStream);
        }
    }

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

// COMMON MISTAKE: Using IFormFile for very large files
// Multipart form parsing can buffer large uploads and consume memory/disk.
// Use MultipartReader for streaming directly to storage.

常见错误

  1. 只配置一种大小限制:必须同时配置 Kestrel MaxRequestBodySize 和 FormOptions.MultipartBodyLengthLimit。
  2. 防伪验证导致 400 错误:在 .NET 8+ 中,UseAntiforgery() 会自动验证表单上传。对于 API 端点,请使用 .DisableAntiforgery()(对于 JWT/未经过身份验证的端点是安全的;对于通过 Cookie 进行身份验证的端点,切勿禁用)。
  3. 信任 file.FileName:用户提供的文件名可能包含路径遍历内容。请使用 Guid.NewGuid() 生成安全文件名,并从已验证的内容中确定扩展名。
  4. 只信任 Content-Type:客户端可以伪造内容类型。始终检查魔数,以验证实际文件类型。
  5. 对超大文件使用 IFormFile:多部分表单解析会先缓冲到内存中,超过内存阈值后则写入临时文件。请使用 MultipartReader 将数据分块直接流式传输到存储中,而不缓冲整个文件。
  6. 根据用户输入确定文件扩展名:优先根据已验证的内容类型或魔数确定扩展名,而不是使用 Path.GetExtension(file.FileName)。如果必须保留原始扩展名,请根据检测到的内容类型验证该扩展名。

评论

支持 Markdown