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

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 つの明示的なルートを置きます。
// 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 の仮想ディレクトリで実行してもパスが正しく機能します。
<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>アクション
[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 になります。
// 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 だけを使っているので、どのコントローラーからでも同じように動作します。
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 は、< や | のような文字で例外をスローするからです。その後、ファイルはランダムな名前で書き込まれます。
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 を起動します。次に、以下を実行します。
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