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

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

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 の仮想ディレクトリで実行してもパスが正しく保たれます。

ASP.NET · Default.aspx
<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 のエントリも必要ありません。

XML · web.config
<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> を使うクラシックパイプラインはテストしていません。

ハンドラー

C# · App_Code/RteUploadHandler.cs
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 でのサイズ制限を参照してください。

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

C# · App_Code/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.");

許可する拡張子は .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 になってしまうからです。

ファイルの保存先

C# · App_Code/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;
    }
}

保存名は、整えたベース名にランダムな 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 が起動します。次に、以下を実行します。

Shell
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

次に読む