ASP.NET Core での安全な画像アップロード:重要なチェック
アップロード用のルートは、アクセスできる人なら誰からでもファイルを受け付けます。そのため、問題になりうることの一つひとつに、それぞれのチェックが必要です。このページでは、ASP.NET Core のサンプルにあるチェックを、リクエストが通過する順に確認します。リクエストの送信元、フォルダー、サイズ、種類、バイト列、保存名、そしてその後のファイルの配信方法です。それぞれについて、意図的に破ったときに得られたレスポンスも紹介します。
チェックとその順序
- オリジン。ブラウザーがほかのサイトから来たものとして示した POST は 403 になります。
- フォルダー。仮想フォルダーは
uploads/内に解決され、存在していなければなりません(400 または 404)。このチェックについては ASP.NET Core のギャラリーフォルダーで説明しています。 - サイズ。4,000,000 バイトを超えると 413 になります。それよりはるかに大きなリクエストは、ASP.NET Core でのアップロードのサイズ制限で紹介している制限によって、より早い段階で止められます。
- 拡張子。
.jpg、.jpeg、.png、.gif、.webpだけです。自分のサイトから配信される SVG はスクリプトを実行できるため、SVG は除外しています。 - バイト列。先頭バイトが拡張子と一致していなければなりません。
- 名前。保存名は生成したものなので、クライアントが付けた名前がディスクに届くことはありません。
ContentType や FileName を信用しない
IFormFile.ContentType はマルチパートのパートの Content-Type で、クライアントが書いた値です。ブラウザーはファイル名からこれを決め、curl は指定されたとおりの値を送信します。サンプルはこれを無視し、代わりに先頭の 12 バイトを読み取ります。
static bool SignatureMatches(string ext, byte[] head) => ext switch
{
".jpg" or ".jpeg" => head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF,
".png" => head.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }),
".gif" => head.AsSpan(0, 4).SequenceEqual("GIF8"u8),
".webp" => head.AsSpan(0, 4).SequenceEqual("RIFF"u8) && head.AsSpan(8, 4).SequenceEqual("WEBP"u8),
_ => false,
};これにより、holiday.jpg に名前を変えたスクリプトは止められます。ただし、ファイルが有効な画像であることの証明にはなりません。ファイルは PNG のように始まり、その後に別のものを含むこともできます。ここでそれが無害になるのは、ファイルの配信方法によるものです(後述)。それ以上が必要な場合は、すべてのアップロードを画像ライブラリでデコードして再エンコードしてください。サンプルでは、依存関係を持たないようにするため、それは行っていません。
// "Red Square.png" -> "red-square"
static string SafeBaseName(string fileName)
{
var name = Regex.Replace(Path.GetFileNameWithoutExtension(fileName).ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
if (name.Length > 40) name = name[..40].TrimEnd('-');
return name.Length == 0 ? "image" : name;
}ベース名を小文字にして a-z、0-9、- だけに絞り込み、RandomNumberGenerator によるランダムな 16 進数 8 文字を加えます。そしてファイルを FileMode.CreateNew で開きます。これは上書きせずに失敗します。
ファイルを安全に配信する
var uploads = RteFiles.UploadsFolder(app.Environment);
Directory.CreateDirectory(uploads);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploads),
RequestPath = "/uploads",
ContentTypeProvider = new FileExtensionContentTypeProvider(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
[".gif"] = "image/gif", [".webp"] = "image/webp",
}),
OnPrepareResponse = ctx => ctx.Context.Response.Headers.XContentTypeOptions = "nosniff",
});ASP.NET Core の既定の FileExtensionContentTypeProvider は .html、.svg、.js を知っているので、アップロードフォルダーに既定の UseStaticFiles を使うと、それらをページやスクリプトとして配信してしまいます。サンプルのプロバイダーが知っているのは 5 種類の画像だけです。ServeUnknownFileTypes は無効のままなので、フォルダー内のそれ以外のものは 404 になります。X-Content-Type-Options: nosniff は、推測せずに宣言された型を使うようブラウザーに指示します。
トークンなしのクロスサイトリクエスト
ASP.NET Core では、クロスサイトリクエストフォージェリへの通常の対策は偽造防止トークンです。エディターのアップロードと「New folder」のリクエストは、rte-files.js とギャラリープラグインが作る XHR で、現在のリリースではそれらにヘッダーやフィールドを追加する方法がありません。そのため、これらのルートでは明示的にトークンの検証を無効にし、代わりにリクエストの送信元をチェックしています。
// CSRF: the editor's upload and create-folder requests are XHRs that carry no antiforgery token,
// so these POSTs refuse requests that a browser marks as coming from another site instead.
// Browsers send Sec-Fetch-Site and Origin on these requests; tools like curl send neither.
static void RequireSameOrigin(HttpRequest request)
{
var site = request.Headers["Sec-Fetch-Site"].ToString();
var origin = request.Headers.Origin.ToString();
var foreign = site is "cross-site" or "same-site"
|| (origin.Length > 0 && !(Uri.TryCreate(origin, UriKind.Absolute, out var o)
&& string.Equals(o.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase)));
if (foreign) throw new RteError(403, "Cross-site request refused.");
}[ApiController]
[Route("rte")]
// [ApiController] actions are not antiforgery-checked unless the app adds a global
// AutoValidateAntiforgeryTokenAttribute. The editor's XHRs cannot send a token, so this opts out
// explicitly; RteFiles checks the request's origin instead.
[IgnoreAntiforgeryToken]ブラウザーはこれらのリクエストに Sec-Fetch-Site と Origin を付けて送信し、ほかのサイトのページがそれらを変更することはできません。curl やサーバー側のコードはどちらも送信しませんが、通過させます。それらはユーザーの Cookie を持っていないからです。Host ヘッダーを変更するリバースプロキシの背後に置く場合は、Request.Host が公開ホストになるよう UseForwardedHeaders を追加してください。最小 API のバージョンでは、同じ選択は .DisableAntiforgery() です。ASP.NET Core でリッチテキストエディターから画像をアップロードするで説明しているとおり、.NET 8 ではこれが必須です。
これらはどれも、誰がアップロードできるかを決めるものではありません。アプリにサインインの仕組みができたら、[Authorize] または .RequireAuthorization() を追加してください。
テストする
このガイドのサンプルプロジェクトを dotnet run で実行し、写真のような名前のスクリプト、SVG、そしてほかのサイトから来たと主張するリクエストを送信します。
echo "not a picture" > holiday.jpg
curl -F "file=@holiday.jpg" http://localhost:5302/rte/upload
{"error":"The file is not a valid .jpg image."}
curl -F "file=@logo.svg" http://localhost:5302/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}
curl -H "Origin: https://evil.example" -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"error":"Cross-site request refused."}
curl -I http://localhost:5302/uploads/blog/photo-65fc24fe.jpg
HTTP/1.1 200 OK
Content-Type: image/jpeg
X-Content-Type-Options: nosniff最初の 2 つは 400、3 つ目は 403 です。ギャラリーでも同じ偽の holiday.jpg は拒否されますが、ダイアログはサーバーのメッセージではなく、独自の汎用的なメッセージを表示します。

ほかのプラットフォーム向けの同じガイド
JavaScript · Next.js · PHP · Razor Pages · MVC 5 · Web Forms