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

RichTextEditor で画像をドロップ、貼り付け、または選択すると、ブラウザーはそのファイルをサーバーに POST し、文書に挿入する URL を待ちます。ASP.NET Web Forms のサイトでは、そのエンドポイントは IHttpHandler です。このページでは、そのハンドラー、それを /rte/upload に配置する web.config の記述、そして見落とすと通常のアップロードが HTML の 500 ページになってしまう .NET Framework の 3 つの注意点を紹介します。
ページ
エディターは /richtexteditor/ から読み込みます。rte-files.js は、サンプルプロジェクトに含まれる短いブラウザー用のファイルです。file という名前のフィールドにファイルを入れ、multipart/form-data として /rte/upload に POST し、{"url": "..."} を読み取ります。Default.aspx では、ResolveUrl によって、サイトをルートではなく IIS の仮想ディレクトリで実行してもパスが正しく保たれます。
<link rel="stylesheet" href="<%= ResolveUrl("~/richtexteditor/rte_theme_default.css") %>" />
<script src="<%= ResolveUrl("~/richtexteditor/rte.js") %>"></script>
<script src="<%= ResolveUrl("~/richtexteditor/plugins/all_plugins.js") %>"></script>
<script>window.RTE_FILES_URL = "<%= ResolveUrl("~/rte") %>";</script>
<script src="<%= ResolveUrl("~/rte-files.js") %>"></script>URL に .ashx を含めずに /rte/upload にハンドラーを置く
Web Forms でよく使われるのはジェネリックハンドラーの upload.ashx です。しかし、ここではそれは機能しません。rte-files.js は URL を RTE_FILES_URL + "/upload" として組み立てるので、.ashx で終わるパスには決して届かないからです。代わりに、ハンドラーは App_Code 内の通常のクラスにし、web.config で拡張子のないパスをそれに対応付けます。ルーティングのコードも Global.asax のエントリも必要ありません。
<handlers>
<!-- The two endpoints. The classes are in App_Code; ASP.NET compiles them on first use. -->
<add name="RteGallery" path="rte/gallery" verb="*" type="RteGalleryHandler" preCondition="integratedMode" />
<add name="RteUpload" path="rte/upload" verb="*" type="RteUploadHandler" preCondition="integratedMode" />
</handlers>web.config のハンドラーの type には App_Code 内のクラスを指定できますが、.ashx ファイルの中で定義されたクラスは指定できません。クラスを App_Code に置いているのはそのためです。マッピングでは preCondition="integratedMode" を使っています。代わりに <system.web><httpHandlers> を使うクラシックパイプラインはテストしていません。
ハンドラー
public void ProcessRequest(HttpContext context)
{
try
{
if (context.Request.HttpMethod != "POST")
{
context.Response.AppendHeader("Allow", "POST");
throw new RteException(405, "Use POST.");
}
HttpPostedFile file;
string folder;
try
{
// Reading the body is where ASP.NET enforces <httpRuntime maxRequestLength>.
// Unvalidated: request validation also covers Request.Files, and a file
// named "<b>.png" would otherwise fail with an HTML 500 page.
file = context.Request.Unvalidated.Files["file"];
folder = context.Request.Unvalidated.Form["folder"];
}
catch (HttpException ex)
{
if (ex.WebEventCode != System.Web.Management.WebEventCodes.RuntimeErrorPostTooLarge) throw;
throw new RteException(413, "The upload is larger than the server allows.");
}
string url = RteFiles.SaveUpload(context, file, folder);
RteFiles.WriteJson(context, 200, new { url = url });
}
catch (RteException ex)
{
RteFiles.WriteError(context, ex.Status, ex.Message);
}
}この中の 2 行は、見た目以上に重要です。
Request.Unvalidated.Files。ASP.NET の要求の検証はRequest.Filesも対象にしており、ページだけでなくハンドラーでも実行されます。この変更を行う前は、<script>x.pngという名前のファイルに対して「A potentially dangerous Request.Files value was detected from the client」という HTML の 500 ページが返りました。これを使うと、同じリクエストはscript-x-xxxxxxxx.pngとして保存されます。ファイル名は、整った保存名を作るためだけに使います。HttpExceptionの catch。<httpRuntime maxRequestLength>は、本文が最初に読み取られるとき、つまりRequest.Filesに触れる行で適用されます。これが発生するタイミングについては Web Forms でのサイズ制限を参照してください。
サーバーがチェックすること
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.");許可する拡張子は .jpg、.jpeg、.png、.gif、.webp です。ファイルは 4,000,000 バイトまでに制限され、先頭バイトは拡張子と一致しなければなりません。HttpPostedFile.ContentType は一切使いません。これはブラウザーが送信するもので、PNG のバイト列を text/html として POST すると、ContentType もそのとおりの値になりました。クライアントのファイル名も、最後の / または \ の位置で自前の処理で切り取ります。.NET Framework 4.8 では、Path.GetFileName("a<b>.png") と Path.GetFileName("a|b.png") が ArgumentException: Illegal characters in path をスローするため、細工した名前で 500 になってしまうからです。
ファイルの保存先
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;
}
}保存名は、整えたベース名にランダムな 16 進数 8 文字を加えたもので、たとえば harbor-2e576eda.jpg のようになります。FileMode.CreateNew により、既存のファイルが上書きされることはありません。
App_Code は、最初のリクエスト時にフレームワーク自体の csc.exe によってコンパイルされます。このマシンではそのバージョンは 4.8.9221.0 で、「only supports language versions up to C# 5」(C# 5 までの言語バージョンしかサポートしない)ものでした。サンプルの App_Code のファイルは $""、?.、=> によるメンバーを使っていないので、Roslyn の CodeDom パッケージなしでコンパイルできます。
テストする
このガイドのサンプルプロジェクトを powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder> で実行します。ビルドの手順なしで、http://localhost:5401/ で IIS Express が起動します。次に、以下を実行します。
curl -F "file=@harbor.jpg" http://localhost:5401/rte/upload
{"url":"/uploads/harbor-2e576eda.jpg"}
echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5401/rte/upload
{"error":"The file is not a valid PNG image."}1 つ目のレスポンスは HTTP 200、2 つ目は HTTP 400 です。ブラウザーで Insert Gallery を開き、Upload をクリックします。ダイアログは rte-files.js を通じて同じリクエストを送信し、保存されたファイルを一覧に表示します。Insert を押すと、それが本文に挿入されます。拒否されたファイルについては、サーバーのテキストではなく、ダイアログ独自の「Upload failed for <name>.」というメッセージが表示されます。
ほかのプラットフォーム向けの同じガイド
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5