ヘッダー
C# サンプル集
 

ファイル選択を使ったアップロード

2024/1/7

この記事は ASP.NET Core Razor Pages を対象にしています。

 

この記事内のサンプルは、プロジェクトテンプレート「ASP.NET Core Web アプリ」を使用して、PagesフォルダーにRazorページを追加する前提です。

 

 

ファイルを選択してアップロードし、サーバーのフォルダーに保存する

この例では、指定した拡張子( .txt,.csv,.tsv,.gif,.png,.jpg,.jpeg,.zip,.xlsx,.pdf )のファイルをアップロードすると、サーバー側のフォルダー C:\temp\uploadに保存します。保存されるファイル名はセキュリティ上の理由でアップロードされたファイル名と一致しません。この例をあなたのパソコンで実行する場合、「サーバー」とはあなたのパソコンのことです。

 

FileUploadSingle.cshtml.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Net;

namespace WorkStandard.Pages.controls;

public class FileUploadSingleModel : PageModel
{
    [BindProperty]
    [DisplayName("ファイルアップロードのテスト")]
    [Required(ErrorMessage = "ファイルが選択されていません。")]
    public IFormFile? uploadFile { get; set; }

    public string result { get; set; } = "";

    public void OnPostUpload()
    {

        //▼自動的に検知できるエラーの確認。(ファイルが選択されていない場合くらいか)
        if (!ModelState.IsValid)
        {
            return;
        }

        //▼空のファイルかチェック
        if (uploadFile!.Length == 0)
        {
            ModelState.AddModelError(nameof(uploadFile), "空のファイルはアップロードできません。");
            return;
        }

        //▼巨大なサイズかチェック
        //「巨大」の基準は状況によります。ここでは検証しやすいように20MBまでとしておきます。
        // 無制限にファイルを受け入れることはDoS攻撃を招きます。
        // 何GBもあるような巨大なファイルの場合、この手法ではなくストリーミングの手法を使います。
        else if (uploadFile.Length > 20 * 1024 * 1024)
        {
            ModelState.AddModelError(nameof(uploadFile), "20MB以上のファイルはアップロードできません。");
            return;
        }


        //▼ファイル名の安全化
        //ファイル名にはさまざまな攻撃用の細工がされている可能性があります。
        //そのため、アップロードされたファイル名を信頼せず、別途保存用のファイル名を生成します。
        //例: Test画像.png → ee4zdnerbe2-Test.png

        //アップロードされたファイルの名前(そのまま扱うと危険)
        string rawFileName = Path.GetFileName(uploadFile.FileName);

        //半角英数と最後のドット(あれば)以外を削除する。
        int dotCount = rawFileName.Count(c => c == '.');
        string asciiName = new string(rawFileName.Where(c =>
            (c == '.' && --dotCount == 0) || (char.IsAsciiLetterOrDigit(c))
        ).ToArray());

        //先頭にランダム文字- を付与。
        string safeFileName = Path.GetRandomFileName().Replace(".", "") + "-" + asciiName;

        //この例では C:\temp\upload に保存します。このフォルダーがないとエラーになります。
        //プログラムのあるフォルダーややwwwroot内には保存しないでください。(攻撃されます。)
        string path = Path.Combine(@"C:\temp\upload", safeFileName);

        //▼パスの長さのチェック

        //Windows10 1607以降では260以上のパスも設定できます。
        const int MAX_PATH = 260;
        if (path.Length > MAX_PATH)
        {
            ModelState.AddModelError(nameof(uploadFile), "ファイル名が長すぎます。");
            return;
        }

        //▼ファイルの存在チェック
        //ファイル名の安全化方式によってはここがシステムファイル等の上書き防御になります。
        if (System.IO.File.Exists(path))
        {
            ModelState.AddModelError(nameof(uploadFile), "このファイル名は受け付けられません。ファイル名を変更してください。");
            return;
        }

        //▼拡張子チェック
        //このサンプルでは.txt .csv .tsv .gif .png .jpg .jpeg .zip .xlsx .pdf を受け入れます。
        //バイナリファイルに対しては拡張子と中身が一致しているかチェックします。
        //exeなど一部の極めて危険な拡張子はブラウザー等の機能で弾かれます。

        //もし、すべてのファイルを受け入れたい場合、この拡張子チェックはまるごとコメントにしてください。
        //その場合、開くだけで攻撃を受けるなど悪意のある内容を含むファイルを保存するリスクが高くなります。

        string EXT = Path.GetExtension(uploadFile.FileName).ToUpperInvariant();

        //アップロードされたファイルの中身をメモリ上に展開します。
        using MemoryStream memoryStream = new();
        uploadFile.CopyTo(memoryStream);

        memoryStream.Position = 0;
        byte[] header;
        using (var reader = new BinaryReader(memoryStream))
        {
            //とりあえず先頭から最大10バイトを読む。
            header = reader.ReadBytes((int)Math.Min(10, memoryStream.Length));
        }

        //内容が正しいことの確認に使用できるシグネチャ
        List<byte[]> signatures = new();

        if (EXT is ".TXT" or ".CSV" or ".TSV")
        {
            //プレーンテキストファイルは内容をチェックせずOKとします。
        }
        else if (EXT is ".GIF")
            signatures = new List<byte[]> {
                new byte[] { 0x47, 0x49, 0x46, 0x38 }
            };
        else if (EXT is ".PNG")
            signatures = new List<byte[]> {
                new byte[]{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }
            };
        else if (EXT is ".JPG" or ".JPEG")
            signatures = new List<byte[]> {
                new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
                new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 },
                new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 },
            };
        else if (EXT is ".ZIP" or ".XLSX")
            signatures = new List<byte[]> {
                new byte[] { 0x50, 0x4B, 0x03, 0x04 },
                new byte[] { 0x50, 0x4B, 0x4C, 0x49, 0x54, 0x45 },
                new byte[] { 0x50, 0x4B, 0x53, 0x70, 0x58 },
                new byte[] { 0x50, 0x4B, 0x05, 0x06 },
                new byte[] { 0x50, 0x4B, 0x07, 0x08 },
                new byte[] { 0x57, 0x69, 0x6E, 0x5A, 0x69, 0x70 },
            };
        else if (EXT is ".PDF")
            signatures = new List<byte[]> {
                new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D }
            };
        else
        {
            ModelState.AddModelError(nameof(uploadFile), "許可されていない拡張子です。");
            return;
        }

        //バイナリファイルの場合、ファイルヘッダーと実際にアップロードされたヘッダーを比較します。
        //この時点で signatures.Count > 0 のものはバイナリファイルです。
        if (signatures.Count > 0)
        {
            bool extOK = false;
            foreach (var signature in signatures)
            {
                if (header.Take(signature.Length).SequenceEqual(signature))
                {
                    extOK = true;
                }
            }

            if (!extOK)
            {
                ModelState.AddModelError(nameof(uploadFile), "ファイルの内容が不正です。");
                return;
            }
        }

        //▼ファイルを保存
        byte[] content = memoryStream.ToArray();

        using (var fileStream = System.IO.File.Create(path))
        {
            fileStream.Write(content);
        }

        //ウィルス対策ソフトなどでファイルの内容のチェックもしてください。
        //メールの添付ファイルと同じで、うかつに開くと悪意のある機能を実行される可能性があります。

        //▼結果を画面に出力
        string safeFileNameForDisplay = WebUtility.HtmlEncode(uploadFile.FileName);
        result = $"{safeFileNameForDisplay} をアップロードしました。";

    } //OnPostUpload
}

 

FileUploadSingle.cshtml

@page
@model WorkStandard.Pages.controls.FileUploadSingleModel

<form method="post" enctype="multipart/form-data">
    <div>
        <label asp-for="uploadFile" class="form-label"></label>
        <input asp-for="uploadFile" class="form-control" 
            accept=".txt,.csv,.tsv,.gif,.png,.jpg,.jpeg,.zip,.xlsx,.pdf" />
    </div>
    <div>
        <span asp-validation-for="uploadFile" class="text-danger"></span>
        <p class="text-success">@Model.result</p>
    </div>
    <div>
        <input type="submit" value="アップロード" asp-page-handler="Upload" />
    </div>
</form>

@section Scripts {
    @{
        //クライアント側での検証を有効にします。
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}

主なCSSクラスの効果

メモ:この例ではアップロードできる拡張子を .txt,.csv,.tsv,.gif,.png,.jpg,.jpeg,.zip,.xlsx,.pdf に制限しています。この制限を解除するには、cs側で「▼拡張子チェック」とある部分の70行程度のプログラムをコメントにして、cshtml側でaccept属性を削除してください。拡張子制限を解除すると悪意のあるファイルを受信するリスクが高まります。

メモ:この例のファイルの拡張子チェックではシグネチャをチェックしているだけなので、同じシグネチャのファイルの拡張子を偽装するとアップロードに成功します。たとえば、 .xlsm のファイルの拡張子を .xlsx に変更するとアップロードに成功します。.txtのファイルはチェックしないため、拡張子を.txtに偽装するとアップロードに成功します。

メモ:この他の拡張子を許可したい場合、正しいファイルのシグネチャを知るには(通常は先頭の数バイト)は各ファイルの仕様を確認する必要があります。手軽に知るには公開されているシグネチャデータベースを参照します。https://www.google.com/search?q=file+signatures+databases&hl=en 

 

 

単一ファイルのアップロード シンプル版(セキュリティ等考慮なし)

このサンプルはセキュリティリスクが高いので学習用途でのみ使用してください。

FileUploadSingleInsecure.cshtml.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel;

namespace WorkStandard.Pages.controls
{
    public class FileUploadSingleInsecureModel : PageModel
    {
        [BindProperty]
        [DisplayName("ファイルアップロードのテスト")]
        public IFormFile? uploadFile { get; set; }

        public void OnPostUpload()
        {
            System.Diagnostics.Debug.WriteLine($"アップロードされたファイル:{uploadFile!.FileName}");
            string path = Path.Combine(@"C:\temp\upload", Path.GetFileName(uploadFile!.FileName));

            using FileStream stream = new(path, FileMode.Create);
            uploadFile.CopyTo(stream);
        }

    }
}

Debug.WriteLineが表示される場所

 

FileUploadSingle.cshtml

@page
@model WorkStandard.Pages.controls.FileUploadSingleInsecureModel

<form method="post" enctype="multipart/form-data">
    <div>
        <label asp-for="uploadFile" class="form-label"></label>
        <input asp-for="uploadFile" class="form-control" />
    </div>
    <div>
        <input type="submit" value="アップロード" asp-page-handler="Upload" />
    </div>
</form>

主なCSSクラスの効果

  • form-control inputをBootstrapの外観にします。→ フォーム

メモ:このサンプルは学習用途レベルです。セキュリティも考慮事項した実用的なサンプルや説明は参考:ファイルアップロードの考慮事項をご覧ください。

 

 

複数ファイルのアップロード シンプル版(セキュリティ等考慮なし)

このサンプルはセキュリティリスクが高いので学習用途でのみ使用してください。

FileUploadMultipleInsecure.cshtml.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel;

namespace WorkStandard.Pages.controls
{
    public class FileUploadMultipleInsecureModel : PageModel
    {
        [BindProperty]
        [DisplayName("複数ファイルアップロードのテスト")]
        public List<IFormFile>? uploadFiles { get; set; }

        public void OnPostUpload()
        {
            foreach(IFormFile uploadFile in uploadFiles!)
            {
                System.Diagnostics.Debug.WriteLine($"アップロードされたファイル:{uploadFile!.FileName}");
                string path = Path.Combine(@"C:\temp\upload", Path.GetFileName(uploadFile!.FileName));

                using FileStream stream = new(path, FileMode.Create);
                uploadFile.CopyTo(stream);
            }
        }

    }
}

Debug.WriteLineが表示される場所

 

FileUploadMultipleInsecure.cshtml

@page
@model WorkStandard.Pages.controls.FileUploadMultipleInsecureModel

<form method="post" enctype="multipart/form-data">
    <div>
        <label asp-for="uploadFiles" class="form-label"></label>
        <input asp-for="uploadFiles" class="form-control" multiple />
    </div>
    <div>
        <input type="submit" value="アップロード" asp-page-handler="Upload" />
    </div>
</form>

主なCSSクラスの効果

  • form-control inputをBootstrapの外観にします。→ フォーム

メモ:このサンプルは学習用途レベルです。セキュリティも考慮事項した実用的なサンプルや説明は参考:ファイルアップロードの考慮事項をご覧ください。

 

 

参考:ファイルアップロードの考慮事項

  • ファイルのアップロード機能は強力な攻撃を受けやすく危険です。ディレクトリトラバーサルでシステムの重要なファイルが書き換えられたり、ウイルスが送り込まれたり、巨大なファイルを大量にアップロードされてサーバーの停止を狙われたりします。ファイルの名前や内容の扱いによっては、これを起点にSQLインジェクションやOSコマンドインジェクションなどさらにさまざまな攻撃を受ける可能性もあります。
  • 下記サイトではセキュリティ以外も含めてさまざまな注意事項が説明されており、また、セキュリティを考慮したさまざまなファイルアップロードのサンプルにアクセスできます。この記事もこのサイトを参考にしています。https://learn.microsoft.com/ja-jp/aspnet/core/mvc/models/file-uploads
  • それらのサンプルに直接飛べるリンクはこれです。AspNetCore.Docs/aspnetcore/mvc/models/file-uploads/samples at main · dotnet/AspNetCore.Docs (github.com) ※このリンク先には多数のサンプルがあります。奇妙なのですが、キーボードの . (ドット) を押すと画面が切り替わります。その画面では左側のツリーを右クリックしてサンプルをダウンロードできます。(しかし今はそれができないようです。よくわかりません。)
  • アップロードされたファイルをファイルとして保存するよりも、内容をバイナリーデータにするなどしてデータベースに保存する方がセキュリティの観点でリスクが低くなります。

 


English Version