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. 01Minimal 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. サイズ制限を 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) ではなく、検証済みのコンテンツタイプまたはマジックバイトから拡張子を特定することを推奨します。元の拡張子を保持する必要がある場合は、検出したコンテンツタイプと照合して検証してください。

コメント