ASP.NET MVC 5 でリッチテキストエディターから画像をアップロードする

MVC 5 のサンプルのギャラリーから RichTextEditor に挿入された画像。上部にはすべてのボタンを表示したツールバーがある

RichTextEditor は各画像をサーバーに POST し、返ってきた URL を挿入します。ASP.NET MVC 5 では、これはルート、コントローラーのアクション、そして HttpPostedFileBase で実現します。ファイルをパラメーターとして受け取る、すぐに思いつく書き方のアクションは、ASP.NET の要求の検証が嫌うファイル名で失敗します。このページでは、正しく動作するアクションと、それに必要なルート、そして Visual Studio なしでサンプルをビルドする方法を紹介します。

ルート

rte-files.js は、サンプルプロジェクトに含まれるブラウザー用のファイルです。file という名前のフィールドにファイルを、任意で folder を入れて、multipart/form-data として /rte/upload に POST し、{"url": "..."} が返ってくることを想定しています。Global.asax.cs では、既定の {controller}/{action}/{id} より前に、2 つの明示的なルートを置きます。

C# · Global.asax.cs
// The two endpoints rte-files.js and the gallery dialog call.
routes.MapRoute("RteGallery", "rte/gallery", new { controller = "Rte", action = "Gallery" });
routes.MapRoute("RteUpload", "rte/upload", new { controller = "Rte", action = "Upload" });

ビューでは、エディターと rte-files.js を Url.Content を通して読み込みます。こうすると、サイトを IIS の仮想ディレクトリで実行してもパスが正しく機能します。

Razor · Views/Home/Index.cshtml
<link rel="stylesheet" href="@Url.Content("~/richtexteditor/rte_theme_default.css")" />
<script src="@Url.Content("~/richtexteditor/rte.js")"></script>
<script src="@Url.Content("~/richtexteditor/plugins/all_plugins.js")"></script>
<script>window.RTE_FILES_URL = "@Url.Content("~/rte")";</script>
<script src="@Url.Content("~/rte-files.js")"></script>

アクション

C# · Controllers/RteController.cs
[HttpPost]
public ActionResult Upload()
{
    try
    {
        // Not action parameters: MVC binds HttpPostedFileBase from the *validated*
        // Request.Files even under [ValidateInput(false)], so a file named "<b>.png"
        // would fail with an HTML 500 before this code runs.
        HttpPostedFileBase file = Request.Unvalidated.Files["file"];
        string folder = Request.Unvalidated.Form["folder"];
        string url = RteFiles.SaveUpload(HttpContext, file, folder);
        return JsonStatus(200, new { url = url });
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

ファイルがアクションのパラメーターになっていないのは意図的です。コントローラーには [ValidateInput(false)] が付いています。すべての値を自分で検証するからです。しかし MVC は、この属性があっても、HttpPostedFileBase パラメーターを検証済みの Request.Files からバインドします。Upload(HttpPostedFileBase file, string folder) とした場合、<script>x.png という名前のファイルでは「A potentially dangerous Request.Files value was detected from the client」という HTML の 500 ページが返りました。これはアクションの実行前に起こるため、JSON のエラーを返すことはできません。Request.Unvalidated.Files["file"] は、このチェックなしで同じ HttpPostedFileBase を返します。これを使うと、同じリクエストは script-x-xxxxxxxx.png として保存されます。

エラーは 1 つのヘルパーを通じて返すので、どのレスポンスも適切なステータスを持つ JSON になります。

C# · Controllers/RteController.cs
// Json() refuses GET unless told otherwise (JsonRequestBehavior.DenyGet is the default)
// and caps output at 2,097,152 characters; both are lifted here. Property names are
// written as declared, so the anonymous objects in RteFiles produce camelCase JSON.
JsonResult JsonStatus(int status, object data)
{
    Response.StatusCode = status;
    // Without this, IIS replaces 4xx bodies with its own error page for remote users.
    Response.TrySkipIisCustomErrors = true;
    // Forms authentication would turn a 401 into a 302 to the login page; keep the 401.
    Response.SuppressFormsAuthenticationRedirect = true;
    Response.Cache.SetCacheability(HttpCacheability.NoCache);
    Response.Cache.SetNoStore();
    return new JsonResult
    {
        Data = data,
        JsonRequestBehavior = JsonRequestBehavior.AllowGet,
        MaxJsonLength = int.MaxValue
    };
}

サーバーがチェックすること

ルールは RteFiles.cs にあります。HttpContextBase と HttpPostedFileBase だけを使っているので、どのコントローラーからでも同じように動作します。

C# · RteFiles.cs
StorageRoot root = GetRoot(context);   // 401 here first in per-user mode
if (file == null || file.ContentLength == 0)
    throw new RteException(400, "No file was uploaded.");

// FileName is whatever the browser sent (old IE sends a full path). Don't feed it
// to Path.GetFileName: on .NET Framework that throws on characters like " < > |.
string clientName = file.FileName ?? "";
clientName = clientName.Substring(Math.Max(clientName.LastIndexOf('/'), clientName.LastIndexOf('\\')) + 1);
int dot = clientName.LastIndexOf('.');
string ext = dot >= 0 ? clientName.Substring(dot).ToLowerInvariant() : "";
if (Array.IndexOf(ImageExtensions, ext) < 0)
    throw new RteException(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
if (file.ContentLength > MaxUploadBytes)
    throw new RteException(413, "The image is larger than the 4 MB (4,000,000 bytes) limit.");

string dir = ResolveFolder(root, folder ?? "");

byte[] data = new byte[file.ContentLength];
Stream input = file.InputStream;
int read = 0;
while (read < data.Length)
{
    int n = input.Read(data, read, data.Length - read);
    if (n <= 0) break;
    read += n;
}
// file.ContentType is chosen by the browser (or the attacker), so look at the bytes.
if (read != data.Length || !SignatureMatches(ext, data))
    throw new RteException(400, "The file is not a valid " + ext.Substring(1).ToUpperInvariant() + " image.");

チェックするのは、画像の拡張子であること、4,000,000 バイト以下であること、先頭バイトが拡張子と一致することです。ContentType はブラウザーが決めるものなので無視します。クライアントのファイル名は、最後のスラッシュの位置で自前の処理で切り取ります。.NET Framework の Path.GetFileName は、< や | のような文字で例外をスローするからです。その後、ファイルはランダムな名前で書き込まれます。

C# · RteFiles.cs
string baseName = SanitizeBaseName(dot >= 0 ? clientName.Substring(0, dot) : clientName);
for (int attempt = 0; ; attempt++)
{
    string stored = baseName + "-" + RandomHex(4) + ext;
    string path = Path.Combine(dir, stored);
    try
    {
        // CreateNew: never overwrite an existing file, even on a random-name collision.
        using (FileStream output = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
            output.Write(data, 0, data.Length);
        return UrlFor(root, folder ?? "", stored);
    }
    catch (IOException)
    {
        if (attempt >= 4 || !File.Exists(path)) throw;
    }
}

Visual Studio なしでビルドする

サンプルは net48 をターゲットにした SDK 形式のプロジェクトです。出力先は IIS がアセンブリを探す bin\ で、Microsoft.AspNet.Mvc 5.3.0 を参照しています。また Microsoft.NETFramework.ReferenceAssemblies も参照しているので、Targeting Pack は不要です。bin も obj もないフォルダーから始めたところ、dotnet build がパッケージを復元して System.Web.Mvc.dll、Razor、WebPages を bin\ にコピーし、IIS Express でサイトが動作しました。Razor ビューは、ほかの MVC 5 アプリと同様に、実行時に ASP.NET がコンパイルします。コントローラーと RteFiles.cs は、packages.config を使う既存の Visual Studio プロジェクトにもそのまま組み込めます。

テストする

このガイドのサンプルプロジェクトを powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder> で実行します。このスクリプトは dotnet build を実行してから、http://localhost:5402/ で IIS Express を起動します。次に、以下を実行します。

Shell
curl -F "file=@harbor.jpg" http://localhost:5402/rte/upload
{"url":"/uploads/harbor-fb0799c5.jpg"}

echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5402/rte/upload
{"error":"The file is not a valid PNG image."}

レスポンスは順に HTTP 200 と HTTP 400 です。ブラウザーでは、Insert Gallery、続いて Upload で同じリクエストが送信され、Insert で画像が本文に挿入されます。拒否されたファイルについては、ダイアログはサーバーのメッセージではなく、独自の「Upload failed for <name>.」を表示します。

ほかのプラットフォーム向けの同じガイド

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms

次に読む