Razor Pages での安全な画像アップロード:偽造防止とオリジン

図:ページのフォームの POST は偽造防止トークンを含み Razor Pages によって検証される。一方、エディターの XHR は Sec-Fetch-Site と Origin をチェックするエンドポイントに送られる

Razor Pages は、すべての POST ハンドラーを偽造防止トークンで保護しています。<form> タグヘルパーがトークンを追加してくれるので、ほとんどの Razor Pages の開発者はこれを意識することがありません。しかし、エディターの画像アップロードと「New folder」のリクエストはフォームではありません。エディター自身が作る XHR で、現在のリリースではそれらにトークンを追加できません。このページでは、サンプルがページの保護を維持する方法、それらのリクエストに対して代わりに行っていること、そして結局トークンを使うには何が必要かを紹介します。

Razor Pages がチェックすること

ページハンドラーへの POST では、Razor Pages は 2 つの値を比較します。Cookie と、リクエストトークンです。リクエストトークンは、既定ではフォームフィールド __RequestVerificationToken またはヘッダー RequestVerificationToken として届きます。.NET 8 で AntiforgeryOptions の既定値を出力したところ、この 2 つの名前と、Cookie の SameSite=Strict でした。サンプルでは、ページの「Save」フォームはこのチェックを通過し、ページへの単純な curl -d "Html=x" は 400 になります。

C# · Pages/Index.cshtml.cs
// Razor Pages validates the antiforgery token on this POST by default; the <form> tag helper
// in Index.cshtml adds the hidden __RequestVerificationToken field. Save Html to your database here.
public void OnPost() => Message = $"Saved {Html?.Length ?? 0} characters of HTML.";

エディターのリクエストにトークンがない理由

アップロードのリクエストは rte-files.js から、「New folder」のリクエストはギャラリープラグインから送られます。どちらも独自の FormData と XMLHttpRequest を作り、現在のリリースではどちらにも追加のヘッダーやフィールドを指定するオプションがありません。トークンを挿入するために XMLHttpRequest をグローバルに書き換えると、ページ上のすべてのリクエストが変わってしまうため、サンプルではそれを行っていません。

残る方法は、3 つのルートをページの外のエンドポイントにして、トークンの検証を明示的に無効にすることです。.NET 8 では、IFormFile や [FromForm] をバインドするエンドポイントにはこれが必須です。.DisableAntiforgery() がないと、Razor Pages アプリは、app.UseAntiforgery() がない場合は 500 を、ある場合は 400 を返しました。

C# · RteEndpoints.cs
// Binding [FromForm] or IFormFile makes .NET 8 demand an antiforgery token on these endpoints.
// The editor's XHRs cannot send one, so it is switched off here and RteFiles checks the
// request's origin instead (see RequireSameOrigin). guide-notes.md has the details.
rte.MapPost("/gallery", (HttpContext http, [FromForm] string? action, [FromForm] string? folder, [FromForm] string? name) =>
    Json(RteFiles.CreateFolder(http, action, folder, name)))
    .DisableAntiforgery();

トークンの代わりのチェック

C# · RteFiles.cs
// CSRF: the editor's upload and create-folder requests are XHRs that carry no antiforgery token,
// so these POSTs refuse requests that a browser marks as coming from another site instead.
// Browsers send Sec-Fetch-Site and Origin on these requests; tools like curl send neither.
static void RequireSameOrigin(HttpRequest request)
{
    var site = request.Headers["Sec-Fetch-Site"].ToString();
    var origin = request.Headers.Origin.ToString();
    var foreign = site is "cross-site" or "same-site"
        || (origin.Length > 0 && !(Uri.TryCreate(origin, UriKind.Absolute, out var o)
            && string.Equals(o.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase)));
    if (foreign) throw new RteError(403, "Cross-site request refused.");
}

クロスサイトリクエストフォージェリとは、ほかのサイトのページが、ユーザーのブラウザーから自分のサイトに POST させることです。ブラウザーはそうしたリクエストにラベルを付けます。Sec-Fetch-Site はリクエストが同一オリジン、同一サイト、別のサイトのどこから来たかを示し、Origin はページのオリジンを示します。ほかのサイトのスクリプトは、どちらのヘッダーも設定できません。そのため、アップロードと「New folder」のルートは、Sec-Fetch-Site が cross-site または same-site の場合、あるいは Origin が別のホストの場合に 403 を返します。Chromium では、サンプル自身のページからのアップロードは通過しました。curl やサーバーのコードからのリクエストはどちらのヘッダーも送信しませんが、ユーザーの Cookie を持つことができないため、許可されます。

これに頼る前に知っておくべきことが 2 つあります。

  • リバースプロキシの背後では、Request.Host が公開ホストでなければなりません。UseForwardedHeaders を追加してください。そうしないと、このチェックは自分のページからのリクエストも拒否します。
  • same-site は意図的に拒否しています。blog.example.com のような兄弟サブドメインは、Cookie を共有していても別のアプリケーションだからです。

代わりにトークンを使う場合

rte-files.js とギャラリープラグインが追加のリクエストヘッダーを受け付けられるなら、ページは IAntiforgery.GetAndStoreTokens(HttpContext).RequestToken から得たリクエストトークンを RequestVerificationToken ヘッダーで渡せます。その場合は .DisableAntiforgery() を削除して app.UseAntiforgery() を追加すれば、エンドポイントはページと同じようにトークンを検証します。現在のリリースにはそのようなオプションがないため、サンプルではこれが動作するところをお見せすることはできません。

そのほかのチェック

オリジンは、いくつかあるチェックの最初のものです。その後、RteFiles.Save がフォルダー、サイズ、拡張子、先頭バイトをチェックし、生成した名前でファイルを保存します。そしてフォルダーは、5 種類のコンテンツタイプと nosniff だけで配信されます。これらについては ASP.NET Core での安全な画像アップロードで順に説明しています。このサンプルでもコードは同じです。

テストする

このガイドのサンプルプロジェクトを dotnet run で実行し、ほかのサイトになりすましたリクエストと、トークンなしでのページへの POST を送信します。

Shell
curl -H "Origin: https://evil.example" -F "action=create-folder" -F "folder=" -F "name=x" http://localhost:5303/rte/gallery
{"error":"Cross-site request refused."}

curl -s -o /dev/null -w "%{http_code}\n" -d "Html=x" http://localhost:5303/
400

1 つ目はサンプルによる 403 です。2 つ目は、ページに対する Razor Pages 自体の偽造防止チェックによるものです。

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

JavaScript · Next.js · PHP · ASP.NET Core · MVC 5 · Web Forms

次に読む