关于 minimal-api-file-upload
面向 .NET 8+ ASP.NET Core Minimal APIs 文件上传端点的技能,涵盖参数绑定、大小限制、防伪验证、文件校验和流式处理。
主要功能
- 01IFormFile 绑定说明
- 02分别配置请求与多部分大小限制
- 03防伪验证处理
- 04内容类型与文件签名校验
- 05MultipartReader 流式处理说明
适用场景
- 01为 Minimal API 添加上传端点
- 02校验上传文件类型
- 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.
常见错误
- 只配置一种大小限制:必须同时配置 Kestrel
MaxRequestBodySize和FormOptions.MultipartBodyLengthLimit。 - 防伪验证导致 400 错误:在 .NET 8+ 中,
UseAntiforgery()会自动验证表单上传。对于 API 端点,请使用.DisableAntiforgery()(对于 JWT/未经过身份验证的端点是安全的;对于通过 Cookie 进行身份验证的端点,切勿禁用)。 - 信任 file.FileName:用户提供的文件名可能包含路径遍历内容。请使用
Guid.NewGuid()生成安全文件名,并从已验证的内容中确定扩展名。 - 只信任 Content-Type:客户端可以伪造内容类型。始终检查魔数,以验证实际文件类型。
- 对超大文件使用 IFormFile:多部分表单解析会先缓冲到内存中,超过内存阈值后则写入临时文件。请使用
MultipartReader将数据分块直接流式传输到存储中,而不缓冲整个文件。 - 根据用户输入确定文件扩展名:优先根据已验证的内容类型或魔数确定扩展名,而不是使用
Path.GetExtension(file.FileName)。如果必须保留原始扩展名,请根据检测到的内容类型验证该扩展名。
评论
支持 Markdown