mcpField
M
minimal-api-file-upload

minimal-api-file-upload

github.com/midudev开发7.0k56 次安装2 周前更新
代码仓库

关于 minimal-api-file-upload

面向 .NET 8+ ASP.NET Core Minimal API 文件上传的实践技能,涵盖参数绑定、大小限制、验证、防伪令牌与大文件流式处理。

主要功能

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

适用场景

  1. 01构建 Minimal API 上传端点
  2. 02在表单上传中添加文件元数据
  3. 03验证上传图像类型
  4. 04避免完整缓冲地处理大文件

安装

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

$npx skills add https://github.com/midudev/autoskills --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