コンテンツにスキップ

第7章(前編):ファイル受信と検証

第7章は前編と後編に分かれています。前編では、ブラウザやクライアントから送られてきたファイルを受け取る仕組みと、受け取ったファイルを安全に検証する方法を説明します。受け取ったファイルを Azure Blob Storage へ保存する方法は 第7章(後編):Azure Blob Storage への保存 で扱います。


  1. ファイル受信の仕組み
  2. アップロードファイルの検証
  3. 参考ドキュメント

Web ブラウザからファイルを送信する場合、HTML フォームの enctype 属性に multipart/form-data を指定します。 このとき HTTP リクエストのボディは、境界文字列 (boundary) で区切られた複数の セクション (section) の並びになります。各セクションは Content-Disposition ヘッダーを持ち、通常のフォーム値かファイルかを判別できます。

<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="title" />
<input type="file" name="file" />
<button type="submit">アップロード</button>
</form>

上記フォームが送信するリクエストボディは、概念的には次のような構造になります。

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary1234
------Boundary1234
Content-Disposition: form-data; name="title"
サンプル画像
------Boundary1234
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
(ここにファイルのバイナリデータ)
------Boundary1234--

バッファリングとストリーミング

Section titled “バッファリングとストリーミング”

ASP.NET Core には、ファイルを受信する方法が 2 つあります。

方式概要代表的な API
バッファリング (Buffering)リクエスト全体をフレームワークが解析し、ファイル 1 件を IFormFile として組み立てる。モデルバインディングで受け取るIFormFile / IFormFileCollection
ストリーミング (Streaming)multipart のセクションを順に読み進め、ファイルの中身を直接保存先へ流し込むMultipartReader

ここで登場する IFormFile は、アップロードされた 1 件のファイルを表す ASP.NET Core 標準のインターフェイス(名前空間 Microsoft.AspNetCore.Http)です。フレームワークが multipart のパートを解析し終えた結果として作られるオブジェクトで、ファイル名・サイズ・MIME タイプといったメタデータと、中身を読み取るためのストリームをひとまとめにして公開します。他言語のフレームワークにある「アップロードファイルオブジェクト」に相当するもので、Spring Boot の MultipartFile、Django の UploadedFile、Laravel の Illuminate\Http\UploadedFile、NestJS (Express) の Express.Multer.File と同じ役割です。詳細は次の項で説明します。

バッファリングでは、フレームワークがファイル全体を メモリまたはディスク上の一時ファイル にいったん保持します。既定では 64 KB (MemoryBufferThreshold) を超えたファイルはメモリからディスクの一時ファイルへ移されます。一時ファイルの出力先は環境変数 ASPNETCORE_TEMP で指定でき、未設定の場合は実行ユーザーの一時フォルダーが使われます。

flowchart TB
    subgraph buffered ["バッファリング(IFormFile)"]
        direction TB
        BReq["HTTP リクエスト\n(multipart/form-data)"] --> BFw["フレームワークが\n全体を解析"]
        BFw --> BBuf["メモリ or ディスクの\n一時ファイル"]
        BBuf --> BApp["IFormFile として\nアクション引数に渡る"]
        BApp --> BDest["保存先\n(ファイル / DB / Blob)"]
    end
    subgraph streamed ["ストリーミング(MultipartReader)"]
        direction TB
        SReq["HTTP リクエスト\n(multipart/form-data)"] --> SRead["MultipartReader が\nセクション単位で読み取り"]
        SRead --> SDest["保存先へ直接\nCopyToAsync"]
    end

IFormFile によるバッファリング受信

Section titled “IFormFile によるバッファリング受信”

小さなファイルを受け取る場合は IFormFile を使います。前項で触れたとおり、IFormFile はアップロードされたファイル 1 件を表すインターフェイスで、実体はフレームワークが用意する FormFile クラスです。ファイルの中身はメモリまたはディスク上の一時ファイルに保持されており、IFormFile はそこへのアクセス手段を提供しているにすぎません。したがって、リクエストが完了した後に IFormFile を保持しても中身は読めなくなる 点に注意してください。バックグラウンド処理へ渡すつもりで IFormFile を保持し、後から OpenReadStream() を呼ぶと ObjectDisposedExceptionCannot access a disposed object.)になります。読み取りや保存は、必ずリクエスト処理中に行います。

MVC コントローラーでは、フォームフィールド名と一致する名前の引数を宣言するだけでモデルバインディングが機能します(モデルバインディングの仕組み全般は第3章:モデルバインディングを参照)。

using Microsoft.AspNetCore.Mvc;
namespace FileUploadSample.Controllers;
[ApiController]
[Route("api/[controller]")]
public class UploadsController : ControllerBase
{
// フォームの <input name="file"> と引数名 file を一致させる。
// なお、このコントローラーの URL は [Route] により /api/uploads になるため、
// 前掲のフォームを使うなら action もそれに合わせる
[HttpPost]
public async Task<IActionResult> Post(IFormFile file, CancellationToken cancellationToken)
{
if (file.Length == 0)
{
return BadRequest("ファイルが空です。");
}
// クライアントが送ってきたファイル名は信用しない(後述)
var safeFileName = $"{Guid.NewGuid():N}{Path.GetExtension(file.FileName).ToLowerInvariant()}";
var savePath = Path.Combine(Path.GetTempPath(), safeFileName);
await using var destination = System.IO.File.Create(savePath);
await file.CopyToAsync(destination, cancellationToken);
return Ok(new { savedAs = safeFileName, size = file.Length });
}
}

コード中の Guid は、他の多くの言語で UUID と呼ばれているものの .NET での名称です。:N はハイフンなしの 32 桁の 16 進数として文字列化する書式指定で、ファイル名に使いやすい形になります。

IFormFile が公開する主なメンバーは次のとおりです。

メンバー説明
FileNameクライアントが送信したファイル名。信用してはいけない値
Lengthファイルサイズ(バイト)
ContentTypeクライアントが申告した MIME タイプ。これも信用してはいけない
Nameフォームフィールド名
OpenReadStream()内容を読み取る Stream を取得する。呼び出すたびに、先頭を指す新しいストリームが返る
CopyToAsync(Stream)内容を指定したストリームへコピーする

複数ファイルを受け取る場合は IFormFileCollection または List<IFormFile> を使います。

// フォーム側も <input type="file" name="files" multiple /> のように
// 引数名 files と一致させる必要がある
[HttpPost("multiple")]
public async Task<IActionResult> PostMultiple(
IFormFileCollection files,
CancellationToken cancellationToken)
{
var results = new List<string>();
foreach (var file in files)
{
var safeFileName = $"{Guid.NewGuid():N}{Path.GetExtension(file.FileName).ToLowerInvariant()}";
await using var destination = System.IO.File.Create(Path.Combine(Path.GetTempPath(), safeFileName));
await file.CopyToAsync(destination, cancellationToken);
results.Add(safeFileName);
}
return Ok(results);
}

ファイル以外のフォーム値と組み合わせる場合は、モデルクラスにまとめると読みやすくなります。

// モデルクラスはコントローラーの外側(FileUploadSample.Models 名前空間)に定義する
public sealed class UploadRequest
{
public required string Title { get; init; }
public string? Description { get; init; }
public required IFormFile File { get; init; }
}
// 以下のアクションメソッドは、前掲の UploadsController の中に追加する
[HttpPost("with-metadata")]
public async Task<IActionResult> PostWithMetadata(
[FromForm] UploadRequest request,
CancellationToken cancellationToken)
{
// request.Title, request.File などにバインドされる
return Ok();
}

Minimal API でも IFormFile / IFormFileCollection をハンドラーの引数に宣言できます(Minimal API のバインディング全般は第4章:各種入力のバインディングを参照)。ただし フォームからのバインドには非フォージェリトークン (antiforgery token) の検証が必須 である点が MVC と異なります。

非フォージェリトークン(antiforgery token、アンチフォージェリトークンとも呼ばれます)は、クロスサイトリクエストフォージェリ (CSRF) 攻撃 を防ぐための仕組みです。

CSRF とは、利用者が正規サイトにログインした状態のまま攻撃者のページを開くと、そのページに仕込まれたフォームが利用者の Cookie を伴って正規サイトへ送信されてしまう、という攻撃です。ブラウザは送信先のドメインに紐づく Cookie を自動的に付けるため、サーバーからは正規の利用者による操作と見分けが付きません。ファイルアップロードのエンドポイントがこれを許すと、意図しないファイルを勝手にアップロードされる恐れがあります。

これを防ぐため、ASP.NET Core は次の 2 つの値をペアで発行します。

送信経路役割
Cookie トークンCookieブラウザが自動的に送信する
リクエストトークンフォームの hidden フィールド、または RequestVerificationToken ヘッダーアプリが明示的に埋め込む

攻撃者のページは正規サイトの HTML を読み取れないため、リクエストトークンの値を知り得ません。サーバーは 2 つの値が対になっているかを検証し、対になっていなければリクエストを HTTP 400 で拒否します。

他言語のフレームワークにも同じ仕組みがあります。Django の {% csrf_token %}CsrfViewMiddleware、Laravel の @csrf と CSRF 対策ミドルウェア(Laravel 13 の PreventRequestForgery、11・12 の ValidateCsrfToken、10 以前の VerifyCsrfToken)、Spring Security の CsrfFilterCsrfToken に相当します。

ASP.NET Core では、非フォージェリ機能AddAntiforgery() でサービスとして登録し、UseAntiforgery() ミドルウェアをパイプラインに追加することで有効になります。Minimal API では、このミドルウェアが IFormFile[FromForm] にバインドするエンドポイントを自動的に検証対象とするため、アプリ側に検証コードを書く必要はありません

using Microsoft.AspNetCore.Antiforgery;
var builder = WebApplication.CreateBuilder(args);
// (1) 非フォージェリトークンの生成・検証を行うサービスを登録する
builder.Services.AddAntiforgery();
var app = builder.Build();
// (2) このミドルウェアが検証を実行する。
// IFormFile / [FromForm] にバインドするエンドポイントは自動的に検証対象となり、
// トークンが無い、または不正な場合はハンドラーに到達せず HTTP 400 が返る。
// エンドポイントのマッピングより前に置くこと。
app.UseAntiforgery();
// (3) ハンドラー自身には検証コードを書かない。
// ここに処理が到達している時点で、検証は (2) で成功済み。
app.MapPost("/upload", async (IFormFile file, CancellationToken cancellationToken) =>
{
var safeFileName = $"{Guid.NewGuid():N}{Path.GetExtension(file.FileName).ToLowerInvariant()}";
var savePath = Path.Combine(Path.GetTempPath(), safeFileName);
await using var destination = File.Create(savePath);
await file.CopyToAsync(destination, cancellationToken);
return TypedResults.Ok(new { savedAs = safeFileName, size = file.Length });
});
app.Run();

ブラウザのフォームから送信する場合は、IAntiforgery で生成したリクエストトークンを hidden フィールドとして埋め込みます。これが上記 (2) で検証される値です。

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
// Cookie トークンをレスポンスの Cookie に書き込み、対になるリクエストトークンを取得する
var token = antiforgery.GetAndStoreTokens(context);
var html = $"""
<html>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
<!-- token.FormFieldName は既定で "__RequestVerificationToken"。
この hidden フィールドが無いと POST は HTTP 400 で拒否される -->
<input name="{token.FormFieldName}" type="hidden" value="{token.RequestToken}" />
<input type="file" name="file" accept=".jpg,.jpeg,.png" />
<input type="submit" value="アップロード" />
</form>
</body>
</html>
""";
return Results.Content(html, "text/html");
});

Cookie 認証を使わない API(Bearer トークン認証など)で、CSRF の攻撃対象にならないことが明らかなエンドポイントは、DisableAntiforgery() で検証を無効化できます。

app.MapPost("/api/upload", async (IFormFile file) => { /* ... */ })
.RequireAuthorization()
.DisableAntiforgery();

ファイルアップロードでは、複数のレイヤーにサイズ制限が存在します。どこで弾かれているのかを把握しておくことが重要です。

flowchart TB
    C["クライアント"] --> R["リバースプロキシ / IIS\nmaxAllowedContentLength\n既定 30,000,000 バイト(約 28.6 MB)"]
    R --> K["Kestrel\nLimits.MaxRequestBodySize\n既定 30,000,000 バイト(約 28.6 MB)"]
    K --> F["FormOptions\nMultipartBodyLengthLimit\n既定 134,217,728 バイト(128 MB)"]
    F --> A["アプリケーションの検証ロジック\n(業務要件に応じた上限)"]
設定既定値超過時の挙動
IIS の maxAllowedContentLength30,000,000 バイト(約 28.6 MB)HTTP 404.13 が返る
KestrelServerLimits.MaxRequestBodySize30,000,000 バイト(約 28.6 MB)BadHttpRequestException がスローされる。クライアントに返るステータスは後述のとおり経路によって異なる
FormOptions.MultipartBodyLengthLimit134,217,728 バイト(128 MB)InvalidDataException がスローされる
FormOptions.MemoryBufferThreshold65,536 バイト(64 KB)閾値を超えた時点で、それまでメモリに保持していた分も含めて全量がディスク上の一時ファイルへ退避される
FormOptions.ValueCountLimit1,024 個InvalidDataException がスローされる。ファイルもこの個数に含まれる

アプリケーション全体で上限を変更する場合は Program.cs で設定します(Kestrel 側の設定項目は Kestrel Web サーバーのオプションを構成するにまとまっています)。

using Microsoft.AspNetCore.Http.Features;
var builder = WebApplication.CreateBuilder(args);
// Kestrel のリクエストボディ上限を 100 MB に変更
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 100 * 1024 * 1024;
});
// multipart の各セクションの上限を 100 MB に変更
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 100 * 1024 * 1024;
});

特定のアクションだけ緩和したい場合は、属性で個別に指定します。

[HttpPost("large")]
[RequestSizeLimit(100 * 1024 * 1024)] // Kestrel のボディ上限
[RequestFormLimits(MultipartBodyLengthLimit = 100 * 1024 * 1024)] // multipart の上限
public async Task<IActionResult> PostLarge(IFormFile file) { /* ... */ }

Minimal API には属性を付ける場所がないため、同じ RequestSizeLimitAttributeエンドポイントのメタデータとして 付与します。

using Microsoft.AspNetCore.Mvc;
app.MapPost("/upload-large", async (IFormFile file) => { /* ... */ })
.WithMetadata(new RequestSizeLimitAttribute(100 * 1024 * 1024));
// 上限を撤廃する場合(本当に必要かをよく検討してください)
app.MapPost("/upload-huge", async (IFormFile file) => { /* ... */ })
.WithMetadata(new DisableRequestSizeLimitAttribute());

条件によって上限を変えたい場合は IHttpMaxRequestBodySizeFeature を使いますが、設定できるのはリクエストボディの読み取りが始まる前だけ です。読み取りが始まった後は IsReadOnlytrue になり、代入すると InvalidOperationExceptionThe maximum request body size cannot be modified after the app has already started reading from the request body.)がスローされます。

using Microsoft.AspNetCore.Http.Features;
// ルーティングより前に置く。ここではまだボディを読んでいない
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/upload-large"))
{
var feature = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (feature is { IsReadOnly: false })
{
feature.MaxRequestBodySize = 100 * 1024 * 1024;
}
}
await next();
});

IIS でホストする場合、上記のコードによる設定だけでは不十分です。maxAllowedContentLength は C# のコードからは変更できません。この値は IIS の要求フィルタリングモジュールが、リクエストが ASP.NET Core アプリに渡される に評価する IIS 自身の設定であり、アプリのコードが実行される時点ではすでに判定が終わっています。そのため、変更するには web.config(またはサーバー全体の applicationHost.config)に記述するしかありません。

<system.webServer>
<security>
<requestFiltering>
<!-- 100 MB。この値は C# のコードからは変更できない -->
<requestLimits maxAllowedContentLength="104857600" />
</requestFiltering>
</security>
</system.webServer>

一方、IIS でホストする場合の アプリ側 のボディ上限は、Kestrel ではなく IISServerOptions.MaxRequestBodySize(既定 30,000,000 バイト)で制御します。IIS の既定のホスティングモデルであるインプロセスホスティングでは、Kestrel ではなく IIS HTTP サーバーがリクエストを処理するため、KestrelServerLimits.MaxRequestBodySize は効きません。

builder.Services.Configure<IISServerOptions>(options =>
{
options.MaxRequestBodySize = 100 * 1024 * 1024;
});

つまり、IIS でホストして 100 MB のアップロードを許可したい場合、両方 の設定が必要です。

設定場所設定項目役割超過時の挙動
web.configmaxAllowedContentLengthIIS がアプリにリクエストを渡すかどうかの判定。ここで弾かれるとアプリには一切届かないHTTP 404.13 が返り、アプリのコードには到達しない
C# コードIISServerOptions.MaxRequestBodySizeアプリがボディの読み取りを許可する上限BadHttpRequestException がスローされる。クライアントに返るステータスは前述のとおり経路によって異なる

<requestLimits> 要素では、maxAllowedContentLength のほかに次の設定も指定できます。ファイルアップロードで直接必要になることは多くありませんが、同じ要素にまとまっているため把握しておくとよいでしょう。

属性 / 子要素既定値説明
maxAllowedContentLength30,000,000リクエストボディの最大長(バイト)
maxUrl4,096URL の最大長(バイト)
maxQueryString2,048クエリ文字列の最大長(バイト)
<headerLimits>なし個々の HTTP ヘッダーごとの最大長を指定する子要素

サイズ以外の制限: 最低データレート

Section titled “サイズ以外の制限: 最低データレート”

大きなファイルのアップロードでは、サイズ上限だけでなく 転送速度の下限 にも注意が必要です。Kestrel は 1 秒ごとにリクエストボディの受信速度を測り、下限を下回った接続をタイムアウトさせます。これは、極端に遅い速度でリクエストを送り続けて接続を占有する Slowloris 型の攻撃を防ぐための仕組みです。

プロパティ既定値説明
Limits.MinRequestBodyDataRate240 バイト/秒、猶予期間 5 秒リクエストボディの受信速度の下限
Limits.MinResponseDataRate240 バイト/秒、猶予期間 5 秒応答の送信速度の下限

猶予期間は、TCP 接続の開始直後は転送速度が徐々にしか上がらない(TCP スロースタート)ことを考慮したもので、この間は速度が測定されません。実際に速度を変えてアップロードすると、次のように挙動が分かれます。

クライアントの送信速度結果
1,000 バイト/秒(下限以上)HTTP 200。10 KB の送信に約 10 秒かかっても成功する
200 バイト/秒(下限未満)HTTP 408 Request Timeout。猶予期間の 5 秒を過ぎた直後に接続が切られる

つまり 時間がかかること自体は問題ではなく、速度が下限を下回ることが問題 です。モバイル回線など低速な環境からの大きなファイルアップロードを想定する場合は、下限を緩めます。

using Microsoft.AspNetCore.Server.Kestrel.Core;
builder.WebHost.ConfigureKestrel(options =>
{
// 100 バイト/秒まで許容し、猶予期間を 30 秒に延ばす
options.Limits.MinRequestBodyDataRate =
new MinDataRate(bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(30));
});

MultipartReader によるストリーミング受信

Section titled “MultipartReader によるストリーミング受信”

ストリーミング受信のコードは、IFormFile に比べると確かに長くなります。ただしこれは「ASP.NET Core がストリーミングをサポートしていない」ということではありません。MultipartReader はフレームワークが標準で提供する公式のユーティリティであり、公式ドキュメント「ASP.NET Core でファイルをアップロードする」でもストリーミングの推奨手段として案内されています。

長くなる理由は、モデルバインディングによる自動化と、バッファリングを避けることが原理的に両立しないため です。モデルバインディングが IFormFile という「値」を引数に渡すためには、その時点でファイルの中身がどこかに確保されていなければなりません。バッファリングを避けるということは、フレームワークがボディを読み終える前にアプリのコードへ制御を渡すということであり、その結果「どのセクションをどこへ流すか」の判断はアプリ側の責務になります。

ASP.NET Core がファイル受信のために提供している手段は、抽象度の順に次の 4 つです。

手段手書きの解析リクエストボディの扱い主な用途
モデルバインディング (IFormFile)不要64 KB を超えるとディスク上の一時ファイルへ全量を退避既定の選択肢
HttpRequest.ReadFormAsync()不要上と同じ(IFormFile の内部で使われている API)フィルターやミドルウェアで動的にフォームを読みたい場合
MultipartReader必要(十数行)一時ファイルを作らず、保存先へ直接転送する大きなファイル
HttpRequest.BodyReader必要PipeReader による最も低レベルの読み取り特殊な最適化が要る場合

つまり自動化を求めるなら選択肢はあり、上 2 つを使えば手書きの解析は不要です。ただしそれらは一時ファイルへの退避を伴います。ストリーミングの目的はまさにこの一時ファイルの往復をなくすこと なので、自動化と目的が両立しない、というのが実態です。

最小構成は次のとおりです。この程度であれば、実装コストはさほど高くありません。

using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
app.MapPost("/upload-stream", async (HttpContext context, CancellationToken cancellationToken) =>
{
// Content-Type ヘッダーから境界文字列を取り出す
var boundary = HeaderUtilities.RemoveQuotes(
MediaTypeHeaderValue.Parse(context.Request.ContentType!).Boundary).Value;
var reader = new MultipartReader(boundary!, context.Request.Body);
MultipartSection? section;
while ((section = await reader.ReadNextSectionAsync(cancellationToken)) is not null)
{
var contentDisposition = section.GetContentDispositionHeader();
if (contentDisposition is not null && contentDisposition.IsFileDisposition())
{
var savePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.bin");
// セクションの本体を保存先へ直接流し込む(一時ファイルを経由しない)
await using var destination = File.Create(savePath);
await section.Body.CopyToAsync(destination, cancellationToken);
}
}
return Results.Ok();
});

ファイル以外のフォーム値も受け取る場合は、IsFormDisposition() の分岐を追加します。

// 前掲の if (contentDisposition.IsFileDisposition()) { ... } に続けて記述する
else if (contentDisposition is not null && contentDisposition.IsFormDisposition())
{
var name = contentDisposition.Name.Value;
using var streamReader = new StreamReader(section.Body);
var value = await streamReader.ReadToEndAsync(cancellationToken);
// 必要に応じて辞書などへ蓄積し、すべて読み終えてからモデルへ詰め替える
}

実運用では、境界文字列の妥当性チェックも加えます。MultipartReader は境界文字列の長さを検証しないため、不正に長い値を送り付けられないよう自前で確認します。

if (!MediaTypeHeaderValue.TryParse(context.Request.ContentType, out var mediaType)
|| string.IsNullOrEmpty(mediaType.Boundary.Value)
|| mediaType.Boundary.Value!.Length > 128) // FormOptions.MultipartBoundaryLengthLimit の既定値
{
return Results.BadRequest("multipart/form-data で送信してください。");
}

ここまでの例では保存先をローカルのファイルシステムにしていますが、保存先が Azure Blob Storage であれば、実装はさらに短くなります。Azure SDK の BlobClient.UploadAsyncStream を直接受け取り、内部でチャンク分割やブロックの並列アップロードまで面倒を見てくれるためです。section.Body をそのまま渡すだけで、ローカルディスクに一切書き込むことなく BLOB へ転送できます。

if (contentDisposition is not null && contentDisposition.IsFileDisposition())
{
// containerClient(BlobContainerClient)の取得方法は後編で解説します
var blobClient = containerClient.GetBlobClient($"{Guid.NewGuid():N}.bin");
// 受信ストリームを、そのまま Blob Storage へ流し込む
await blobClient.UploadAsync(section.Body, overwrite: false, cancellationToken);
}

同じことは IFormFile でも file.OpenReadStream() を渡すだけで実現できます。詳しくは ストリームをそのままアップロードする で扱います。

MVC コントローラーでストリーミングを行う場合は、フォームのモデルバインディングが先にリクエストボディを読み切ってしまわないよう、フォーム値のバインドを無効化するリソースフィルターを適用します(フィルターの種類と実行順序は第3章:フィルター(ActionFilter, ExceptionFilter 等)と横断処理を参照)。

using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace FileUploadSample.Filters;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
public void OnResourceExecuting(ResourceExecutingContext context)
{
var factories = context.ValueProviderFactories;
factories.RemoveType<FormValueProviderFactory>();
factories.RemoveType<FormFileValueProviderFactory>();
factories.RemoveType<JQueryFormValueProviderFactory>();
}
public void OnResourceExecuted(ResourceExecutedContext context)
{
}
}

適用は、ストリーミングを行うアクション(またはコントローラー)に属性を付けるだけです。[RequestSizeLimit][DisableRequestSizeLimit] と併用する場合も、同じように属性として並べます。

[HttpPost("stream")]
[DisableFormValueModelBinding]
[RequestSizeLimit(500 * 1024 * 1024)]
public async Task<IActionResult> UploadStream(CancellationToken cancellationToken)
{
// 本体は前掲の MultipartReader を使ったコードと同じ
/* ... */
}
観点バッファリング(IFormFileストリーミング(MultipartReader
実装の容易さ◎ モデルバインディングで完結△ 自前でセクションを解析する
モデル検証との統合◎ データ注釈が使える(MVC・Minimal API とも)△ 自前で実装が必要
メモリ/ディスク消費△ ファイルサイズと同時実行数に比例◎ 一定のバッファのみ
適したファイルサイズ数十 MB 程度まで数百 MB 以上
適した用途プロフィール画像、添付書類動画、バックアップ、大量データ

2. アップロードファイルの検証

Section titled “2. アップロードファイルの検証”

ファイルアップロードは、攻撃者にとって サーバーへ任意のデータを送り込める入口 です。最低限、次の観点で検証します。

観点内容
サイズ業務要件に応じた上限を設け、超過したら拒否する
拡張子許可リスト(ホワイトリスト)方式で判定する
ファイルシグネチャ先頭バイト列を検査し、拡張子と内容の整合を確認する
ファイル名クライアント由来の名前をそのまま保存パスに使わない
内容ウイルス/マルウェアスキャンを実施する

拡張子とファイルシグネチャの検証

Section titled “拡張子とファイルシグネチャの検証”

拡張子は許可リストで判定します。禁止リスト(ブラックリスト)方式は漏れが生じやすいため使用しません。

private static readonly string[] PermittedExtensions = [".jpg", ".jpeg", ".png", ".pdf"];
private static bool IsPermittedExtension(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return !string.IsNullOrEmpty(extension) && PermittedExtensions.Contains(extension);
}

拡張子はいくらでも詐称できるため、ファイルの先頭数バイト(ファイルシグネチャ/マジックナンバー) も検査します。

using System.Buffers;
namespace FileUploadSample.Validation;
public static class FileSignatureValidator
{
private static readonly Dictionary<string, byte[][]> Signatures = new()
{
// JPEG は SOI マーカー (FF D8) の直後に別のマーカー (FF xx) が続く。
// xx には APP0 (E0) や APP1 (E1) のほか ED や C0 なども現れるため、
// 4 バイト目まで固定すると正常な JPEG を取りこぼす
[".jpg"] = [[0xFF, 0xD8, 0xFF]],
[".png"] = [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]],
[".pdf"] = [[0x25, 0x50, 0x44, 0x46]],
};
public static bool IsValidSignature(Stream stream, string extension)
{
extension = extension.ToLowerInvariant();
// .jpeg は .jpg と同じシグネチャで判定する
var key = extension == ".jpeg" ? ".jpg" : extension;
if (!Signatures.TryGetValue(key, out var candidates))
{
return false;
}
var maxLength = candidates.Max(signature => signature.Length);
var buffer = ArrayPool<byte>.Shared.Rent(maxLength);
try
{
var read = stream.ReadAtLeast(buffer.AsSpan(0, maxLength), maxLength, throwOnEndOfStream: false);
ReadOnlySpan<byte> header = buffer.AsSpan(0, read);
// Span はラムダ式の中で使えないため、foreach で判定する
foreach (var signature in candidates)
{
if (header.Length >= signature.Length
&& header[..signature.Length].SequenceEqual(signature))
{
return true;
}
}
return false;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
stream.Position = 0; // 後続の保存処理のために巻き戻す
}
}
}

シグネチャの定義そのものにも注意が必要です。JPEG のシグネチャを FF D8 FF E0(JFIF)のように 4 バイトで固定している例をよく見かけますが、4 バイト目は後続のマーカー種別であり、E1(Exif)や EE(Adobe)、DB(量子化テーブル)など多くの値を取ります。

実際に手元の JPEG ファイル 3,000 件の 4 バイト目を集計すると、次のように分布しました。

先頭 4 バイト意味件数
FF D8 FF E0APP0(JFIF)2,495
FF D8 FF E1APP1(Exif352
FF D8 FF E2APP2(ICC プロファイル)142
FF D8 FF EEAPP14(Adobe)9
FF D8 FF DBDQT(量子化テーブル)2

なお、この 3,000 件には E3E8 は 1 件も現れませんでした。

クライアントが送ってきたファイル名を、そのまま保存パスの構築に使ってはいけません。../../etc/passwd のようなパストラバーサル攻撃や、既存ファイルの上書きにつながります。

// ❌ 危険: クライアント由来の名前をそのまま使用
// Path.Combine は結合するだけで、".." を解決したり不正な文字を除去したりはしない
var path = Path.Combine(uploadDirectory, file.FileName);
// ✅ 安全: アプリケーションが生成した名前を使用し、拡張子だけを引き継ぐ
// ただし拡張子もクライアント由来なので、許可リストの検証を通したものだけを使う
if (!IsPermittedExtension(file.FileName))
{
return BadRequest("許可されていない拡張子です。");
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
var storedName = $"{Guid.NewGuid():N}{extension}";
var path = Path.Combine(uploadDirectory, storedName);

Path.Combine にも注意が必要です。名前のとおり「結合するだけ」で、安全な保存先に閉じ込めてくれる関数ではありません。

file.FileName の値Path.Combine("/uploads", ...) の結果
photo.png/uploads/photo.png
../../etc/cron.d/job/uploads/../../etc/cron.d/job.. は解決されず、OS が解釈して /etc/cron.d/job に到達する)
/etc/cron.d/job/etc/cron.d/job第 2 引数が絶対パスだと、第 1 引数は捨てられる

Path.Combine の第 2 引数が絶対パスだったときに第 1 引数を無視するのは、Python の os.path.join や Node.js の path.resolve と同じ挙動です(名前の似た Node.js の path.join は、後続の引数が絶対パスでも前の引数を捨てないため、挙動が異なります)。いずれの言語でも「結合先のディレクトリに収まることを保証する関数」ではないため、保存先の安全性は呼び出し側で担保しなければなりません。前掲の ✅ の例のように、アプリケーションが生成した名前だけを使えばこれらの問題はまとめて回避できます。

ファイル名だけでなく、保存先ディレクトリの選び方 も重要です。アップロードされたファイルは、wwwroot の配下に置いてはいけません。wwwrootUseStaticFiles() によって誰でもダウンロードできる公開領域だからです。

実際に wwwroot/uploads/ へファイルを置いて確認すると、次のようになります。

保存したファイル応答理由
a.txtHTTP 200(text/plain既定の MIME 辞書にあるため配信される
x.exeHTTP 200(application/vnd.microsoft.portable-executable実行可能ファイルも既定の MIME 辞書にあるため配信される
shell.aspxHTTP 404MIME 辞書にない拡張子は既定では配信されない
web.configHTTP 404同上

.aspx が 404 になるのは、静的ファイルミドルウェアが既定で未知の拡張子を配信しない(ServeUnknownFileTypesfalse)ためであり、Kestrel が .aspx を実行することはありません。しかし .exe のようにマルウェアそのものになりうるファイルは、そのまま公開ダウンロードできてしまいます。攻撃者がマルウェアをアップロードし、その URL を第三者に配布すれば、あなたのドメインがマルウェア配布の踏み台になります。

保存先は、アプリケーションの配置ディレクトリの外にある専用領域とし、可能であれば実行権限を外してください。後編で解説する Azure Blob Storage への保存は、そもそもアプリケーションのファイルシステムと切り離されているため、この問題を構造的に回避できます

元のファイル名を画面に表示したい場合は、表示用の名前としてデータベースに保持 し、表示時に HTML エンコードします。Razor は既定で出力を HTML エンコードするため安全ですが、Razor 以外で出力する場合は WebUtility.HtmlEncode を明示的に呼び出します。

サイズ上限は構成から読み込み、IOptions<T> で注入するのが定石です(構成の詳細は第5章:アプリ設定 (Configuration)、DI の詳細は第6章:依存性注入 (DI)を参照)。

{
"FileUpload": {
"MaxFileSizeBytes": 5242880,
"PermittedExtensions": [ ".jpg", ".jpeg", ".png", ".pdf" ]
}
}
using System.ComponentModel.DataAnnotations;
namespace FileUploadSample.Validation;
public sealed class FileUploadOptions
{
public const string SectionName = "FileUpload";
[Range(1, 1024L * 1024 * 1024)]
public long MaxFileSizeBytes { get; set; } = 5 * 1024 * 1024;
[Required, MinLength(1)]
public string[] PermittedExtensions { get; set; } = [];
}
using FileUploadSample.Validation;
builder.Services
.AddOptions<FileUploadOptions>()
.Bind(builder.Configuration.GetSection(FileUploadOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();

ValidateDataAnnotations() は、System.ComponentModel.DataAnnotations の属性による検証を 有効にする メソッドです。これだけでは検証は実行されず、IOptions<T>Value に初めてアクセスした時点まで遅延します。ValidateOnStart() を続けて呼ぶことで、その検証がアプリケーションの起動時に実行されるようになり、設定漏れを起動時点で失敗させられます(検証のタイミングについては第5章:バリデーションのタイミングを参照)。上記の例で PermittedExtensions が空のまま起動しようとすると、次のように OptionsValidationException で停止します。

Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for
'FileUploadOptions' members: 'PermittedExtensions' with the error:
'The field PermittedExtensions must be a string or array type with a minimum length of '1'.'.

検証ロジックをサービスとしてまとめておくと、複数のエンドポイントから再利用できます。

using Microsoft.Extensions.Options;
namespace FileUploadSample.Validation;
public interface IUploadValidator
{
UploadValidationResult Validate(IFormFile file);
}
public sealed record UploadValidationResult(bool IsValid, string? ErrorMessage)
{
public static UploadValidationResult Success { get; } = new(true, null);
public static UploadValidationResult Failure(string message) => new(false, message);
}
public sealed class UploadValidator(IOptions<FileUploadOptions> options) : IUploadValidator
{
private readonly FileUploadOptions _options = options.Value;
public UploadValidationResult Validate(IFormFile file)
{
if (file.Length == 0)
{
return UploadValidationResult.Failure("ファイルが空です。");
}
if (file.Length > _options.MaxFileSizeBytes)
{
return UploadValidationResult.Failure(
$"ファイルサイズが上限({_options.MaxFileSizeBytes:N0} バイト)を超えています。");
}
// ToLower ではなく ToLowerInvariant を使う。
// ToLower は実行環境のロケールに従うため、たとえばトルコ語環境では
// ".TIF" が ".tıf"(点のない i)になり、許可リストに一致しなくなる
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
// 比較子を指定する。構成ファイルに ".JPG" と大文字で書かれていても
// 一致させるため(既定の Contains は大文字小文字を区別する)
if (string.IsNullOrEmpty(extension)
|| !_options.PermittedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
// クライアント由来の値をエラーメッセージにそのまま含めない(後述)
return UploadValidationResult.Failure("許可されていない拡張子です。");
}
using var stream = file.OpenReadStream();
if (!FileSignatureValidator.IsValidSignature(stream, extension))
{
return UploadValidationResult.Failure("ファイルの内容が拡張子と一致しません。");
}
return UploadValidationResult.Success;
}
}

Minimal API での検証(.NET 10 の新機能)

Section titled “Minimal API での検証(.NET 10 の新機能)”

MVC コントローラーでは以前からデータ注釈([Required][Range] など)によるモデル検証が動作しましたが(詳細は第3章:入力検証 (バリデーション)を参照)、Minimal API には同等の仕組みがなく、上記のような検証サービスを自分で呼び出す必要がありました。

ASP.NET Core 10 では、Minimal API でもデータ注釈による検証が利用できるようになりました。 AddValidation() を呼ぶだけで、ハンドラーの引数に付けた検証属性がフレームワークによって評価され、違反があればハンドラーに到達せず HTTP 400 と検証エラーの詳細が返ります。この仕組みの全体像は第4章:バリデーションで扱っています。ここではファイルアップロード特有の使い方に絞って説明します。

builder.Services.AddValidation();

ファイルサイズのように標準の属性では表現できない条件は、ValidationAttribute を継承したカスタム属性を作ります。IFormFile に対しても機能します。

using System.ComponentModel.DataAnnotations;
namespace FileUploadSample.Validation;
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class MaxFileSizeAttribute(long maxBytes) : ValidationAttribute
{
public override bool IsValid(object? value)
=> value is not IFormFile file || file.Length <= maxBytes;
public override string FormatErrorMessage(string name)
=> $"{name}{maxBytes:N0} バイト以下にしてください。";
}
using System.ComponentModel.DataAnnotations;
using FileUploadSample.Validation;
using Microsoft.AspNetCore.Mvc;
// ① IFormFile を単体の引数として受け取る場合
app.MapPost("/upload", ([MaxFileSize(5 * 1024 * 1024)] IFormFile file) => TypedResults.Ok());
// ② フォーム値とファイルをまとめた複合型で受け取る場合
app.MapPost("/upload-with-title", ([FromForm] ValidatedUploadRequest request) => TypedResults.Ok());
public class ValidatedUploadRequest
{
[Required, StringLength(100)]
public string Title { get; set; } = "";
// [Required] を付けないとファイル未指定のリクエストが検証を通過してしまう(後述)
[Required, MaxFileSize(5 * 1024 * 1024)]
public IFormFile? File { get; set; }
}

② のエンドポイントに、100 文字を超えるタイトルと上限を超えたファイルを送ると、次のようなレスポンスが返ります。

{
"title": "One or more validation errors occurred.",
"errors": {
"Title": ["The field Title must be a string with a maximum length of 100."],
"File": ["File は 5,242,880 バイト以下にしてください。"]
}
}

公式ドキュメント「ASP.NET Core でファイルをアップロードする」は、アップロードされたファイルを保存する前にウイルス/マルウェアスキャナーを通すこと を強く推奨しています。アップロード機能に潜む危険の全体像は、OWASP の Unrestricted File Upload にまとめられています。スキャンはサーバーリソースを消費するため、大量アップロードが発生するアプリケーションでは次のような非同期処理が推奨されます。なお、以下の図と手順に出てくる「コンテナー」は、Docker などの実行環境のことではなく、Azure Blob Storage がファイルを入れておく区画を指します(後編の Blob Storage のオブジェクトモデル で解説します)。

flowchart TB
    U["クライアント"] --> API["アップロード API"]
    API --> Q["隔離コンテナー\n(quarantine)"]
    API --> DB[("メタデータ DB\nステータス: 検査中")]
    Q --> W["バックグラウンドサービス\n(ウイルススキャン)"]
    W -->|合格| P["公開コンテナー\n(files)"]
    W -->|不合格| X["削除 / 監査ログ"]
    W --> DB
  1. アップロードされたファイルは、まず 隔離用のコンテナー に保存する
  2. データベースには「検査中」というステータスでレコードを作成する
  3. バックグラウンドサービス(BackgroundService)がスキャナー API を呼び出す
  4. 合格したファイルのみを通常のコンテナーへ移動し、ステータスを更新する