WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

ASP.NET の CSRF 対策

by WebSurfer 2023年5月2日 16:45

Visual Studio のテンプレートを使って作成する ASP.NET MVC と Web Forms プロジェクトに組み込まれている Cross-Site Request Forgery (CSRF) 対策について書きます。

Cross-Site Request Forgery (CSRF)

上の図は、Microsoft のドキュメント「ASP.NET Core でクロスサイト リクエスト フォージェリ (XSRF/CSRF) 攻撃を防止する」に述べられている CSRF 攻撃の仕組みを図示したものです。

どういうことかと言うと、(1) ユーザー認証が必要な正規サイトにログインしたクライアントが、ログアウトせず(即ち、認証クッキーを持ったまま)作業を続ける。(2) その後、悪意のあるサイトにアクセスして攻撃用の HTML フォームが仕組まれたページを閲覧し、その攻撃用の HTML フォームのボタンをクリックする。(3) それにより正規サイトに POST 要求がかかるが、認証クッキーも同時に送信されるので、認証ユーザーが実行を許可されているアクションを実行できる・・・ということです。

その攻撃を防止するための組み込みの機能について以下に書きます。

(1) MVC の場合

Microsoft のドキュメント「Web Stack Runtime XSRF の軽減策」に書いてあるように、2 つの CSRF 対策用トークンを用います。

まず、サーバが入力フォームをクライアントに送信する際、クッキーと隠しフィールドに CSRF 対策用トークンを設定してクライアントに渡します。

クライアントが入力フォーム上で入力を済ませて、サーバーにフォームを POST 送信する際、同時にクッキーと隠しフィールドの CSRF 対策用トークンもサーバーに送信されます。

サーバーは、両方のトークンが比較チェックに合格した場合にのみ要求の続行を許可します。不合格の場合はサーバーエラーとなります。

上の図で、「罠を仕込んだページ」の「攻撃用の HTML フォーム」には CSRF 対策用トークンを持った隠しフィールドは存在しないので比較チェックは不合格となり、CSRF 攻撃を防ぐことができるということになります。

(2) Web Forms の場合

Web Froms には状態管理の手段の一つに ViewState というものがあって、隠しフィールドに状態を保存してクライアントに送信し、ポストバックされたときにサーバー側で隠しフィールドから状態情報を取得するという仕組みになっています。

ViewState には EnableViewStateMac という改ざんを検証する機能があって、デフォルトで有効になっているので、ある程度それで CSRF を防ぐことができます。

ただ、それだけでは不十分だそうで、Page.ViewStateUserKey プロパティを利用して、ViewState に個々のユーザーの 識別子を割り当てることが推奨されています。

ViewStateUserKey プロパティを設定した場合、ASP.NET は、ポストバックによってクライアントから送信されてきた隠しフィールドの ViewState から識別子を抽出し、実行中のページの ViewStateUserKey と比較します。 2 つが一致する場合要求は正当と見なされ、それ以外の場合は例外がスローされる仕組みになっているそうです。(そのあたりの詳しい説明は ViewStateUserKey を見てください)

Visual Studio 2022 のテンプレートで作る Web アプリケーションプロジェクトの場合、マスターページ (Site.Master.cs) に、Page.ViewStateUserKey プロパティを利用して CSRF 対策を強化するためのコードが実装されています。

それがどうなっているかを説明します。

上の Microsoft のドキュメントでは Session.SessionID を Page.ViewStateUserKey プロパティに設定する例が紹介されています。しかし、Session を使わない限り Session cookie が発行されないので、そうはできないケースがあります。

なので、Site.Master.cs では、SessionID に代えて Guid の文字列を Page.ViewStateUserKey プロパティに設定しています。そのコードは以下の通りです。

Page_Init で、要求ヘッダに __AntiXsrfToken という名前のクッキーが含まれ、かつ、その値を Guid にパースできる場合、そのクッキーの値を Page.ViewStateUserKey に設定しています。

要求ヘッダに __AntiXsrfToken という名前のクッキーが含まれない場合、含まれていてもその値を Guid にパースできない場合は、Guid を生成してその文字列を Page.ViewStateUserKey に設定します。さらに、__AntiXsrfToken という名前のクッキーを作成し、その値に生成した Guid の文字列を設定してクライアントに送信しています。

using System;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using Microsoft.AspNet.Identity;

namespace WebForms1
{
    public partial class SiteMaster : MasterPage
    {
        private const string AntiXsrfTokenKey = "__AntiXsrfToken";
        private const string AntiXsrfUserNameKey = "__AntiXsrfUserName";
        private string _antiXsrfTokenValue;

        protected void Page_Init(object sender, EventArgs e)
        {
            // 以下のコードは、XSRF 攻撃からの保護に役立ちます
            var requestCookie = Request.Cookies[AntiXsrfTokenKey];
            Guid requestCookieGuidValue;
            if (requestCookie != null && 
                Guid.TryParse(requestCookie.Value, 
                              out requestCookieGuidValue))
            {
                // Cookie の Anti-XSRF トークンを使用します
                _antiXsrfTokenValue = requestCookie.Value;
                Page.ViewStateUserKey = _antiXsrfTokenValue;
            }
            else
            {
                // 新しい Anti-XSRF トークンを生成し、Cookie に保存
                _antiXsrfTokenValue = Guid.NewGuid().ToString("N");
                Page.ViewStateUserKey = _antiXsrfTokenValue;

                var responseCookie = new HttpCookie(AntiXsrfTokenKey)
                {
                    HttpOnly = true,
                    Value = _antiXsrfTokenValue
                };
                if (FormsAuthentication.RequireSSL && 
                    Request.IsSecureConnection)
                {
                    responseCookie.Secure = true;
                }
                Response.Cookies.Set(responseCookie);
            }

            Page.PreLoad += master_Page_PreLoad;
        }

        // ・・・中略・・・

    }
}

さらに、初期画面の要求の処理を行う際 Page_PreLoad で ViewState に Page.ViewStateUserKey の文字列とユーザー名(ユーザーが認証を受けている場合)を設定し、ポストバックの際はそれらが一致しているか否かの検証を行い一致しない場合は例外をスローするという操作を、上のコードで Page.PreLoad にアタッチしたイベントハンドラ master_Page_PreLoad で行っています。

そのコードは以下の通りです。

protected void master_Page_PreLoad(object sender, EventArgs e)
{
    if (!IsPostBack)
    {
        // Anti-XSRF トークンを設定します
        ViewState[AntiXsrfTokenKey] = Page.ViewStateUserKey;
        ViewState[AntiXsrfUserNameKey] = 
                    Context.User.Identity.Name ?? String.Empty;
    }
    else
    {
        // Anti-XSRF トークンを検証します
        if ((string)ViewState[AntiXsrfTokenKey] != _antiXsrfTokenValue ||
            (string)ViewState[AntiXsrfUserNameKey] != 
                         (Context.User.Identity.Name ?? String.Empty))
        {
            throw new InvalidOperationException(
                "Anti-XSRF トークンの検証が失敗しました。");
        }
    }
}

ただし、上に書いた「攻撃用の HTML フォーム」で POST 要求するような場合は ViewState が含まれないので ViewState Mac の検証機能は動きません。また、ポストバックと判定されないので master_Page_PreLoad によるトークンの検証もされません。

それで CSRF 対策になるのかというのが疑問でしたが、普通に ASP.NET Web Forms アプリでやるように、ユーザー入力に TextBox コントロール使い、Button コントロールをクリックしてポストバックし、Button の Click イベントで処置を行うケースでは問題なさそうです。

「攻撃用の HTML フォーム」から送信された値は TextBox.Text に代入されることはなく(TextBox.LoadPostData が呼ばれないので)、Click イベントも発生しないのでサーバー側では何も起こりません。

ちょっと問題なのは「攻撃用の HTML フォーム」は POST 要求を出すのでそれを受け��正規 Website は応答を返すという点です。予期せぬ応答をもらったユーザーはびっくりすると思いますが、その対策までは上のコードには実装されていません。

Tags: , , ,

ASP.NET

User-Agent Client Hints

by WebSurfer 2023年4月21日 15:27

Windows OS のブラウザから、要求ヘッダに含まれて Web サーバーに送信されてくる従来の User-Agent では、OS のバージョンが Windows 10 なのか Windows 11 なのかの識別ができません。

しかし、Microsoft のドキュメント「User-Agent クライアント ヒントを使用してWindows 11と CPU アーキテクチャを検出する」によると、User-Agent Client Hints を利用すればサーバー側で OS のバージョンが Windows 10 なのか Windows 11 なのかの識別ができるとのことなので検証してみました。

なお、ドキュメントには書いてありませんが、HTTPS 通信に限ると言うところに注意してください。また、現時点では実験的な機能であり、対応ブラウザも Edge, Chrome, Opera に限られている点にも注意してください。(参考 : ユーザーエージェントクライアントヒント API

ブラウザはデフォルトで要求ヘッダに User-Agent Client Hints 関係の情報 sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform を含めます (HTTPS 通信の場合に限ります)。Windows 10 22H2 の Microsoft Edge 112.0.1722.48 の場合以下の通りとなります。

sec-ch-ua: "Chromium";v="112", "Microsoft Edge";v="112", "Not:A-Brand";v="99"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"

上の情報からは OS が Windows だというのは分かりますがバージョンは分かりません。バージョン情報もブラウザが送信するようにするには、Web サーバーからの応答ヘッダに、

Accept-CH: Sec-CH-UA-Platform-Version

を含めます。そうすると、次の要求からブラウザが要求ヘッダに、

sec-ch-ua-platform-version: "10.0.0"

という OS のバージョン情報を含めて送信してくれるようになります。 (上の例で OS は Windows 10 22H2)

ただし、ブラウザからの最初の要求にはバージョン情報は含まれず、次の要求からになります。そこが使いにくい点かもしれません。

そこが問題であれば、上に紹介した Microsoft のドキュメントの「検出パフォーマンスの最適化 Critical-CH」セクションに書いてあるように、Accept-CH と一緒に Critical-CH ヘッダーを応答ヘッダに含めてクライアント(ブラウザ)に送るのが良さそうです。

具体的には、例えば、Web サーバーからの応答ヘッダに、

Accept-CH: Sec-CH-UA-Platform-Version
Critical-CH: Sec-CH-UA-Platform-Version 

を含めると、ブラウザは最初の応答を受けた後、直ちに再度要求を出し、その要求ヘッダには Critical-CH ヘッダーに指定された情報(この例では sec-ch-ua-platform-version: "10.0.0")を含めて送信してくれます。

以降は、ユーザーがブラウザの「Cookie およびその他のサイト データ」を消去しない限り、ブラウザは指定された情報を送信し続けます。

また、再度 Accept-CH と一緒に Critical-CH ヘッダーを応答ヘッダに含めて送信しても 2 回要求が出るということはありません。

以下に、Fiddler で要求・応答をキャプチャした画像を貼って説明を加えておきます。

(1) 最初の要求

(1) 最初の要求

Default.aspx.cs の Page_Load に以下の C# コードを実装して要求をかけたものです。

Response.AppendHeader("Accept-CH", "Sec-CH-UA-Platform-Version");
Response.AppendHeader("Critical-CH", "Sec-CH-UA-Platform-Version");

画像の青枠部分がブラウザからの要求ヘッダに含まれる User-Agent Client Hints 情報です。この時点では、バージョン情報 Sec-CH-UA-Platform-Version は含まれていません。

画像の赤枠部分に示した通り、Web サーバーからの応答ヘッダには上の C# コードで指定した Accept-CH と Critical-CH が設定されています。

(2) 自動的に再度要求が出る

(2) 自動的に再度要求が出る

ブラウザは Web サーバーからの応答ヘッダの Accept-CH と Critical-CH を見て自動的に再度 Default.aspx に要求を出します。

画像の青枠の通り要求ヘッダにバージョン情報 sec-ch-ua-platform-version が含まれています。

(3) 他のページを要求

(3) 他のページを要求

Web サーバーからの応答ヘッダに Accept-CH, Critical-CH を含まない他のページ(この例では Contact.aspx)を要求してみます。

画像の青枠の通り要求ヘッダに sec-ch-ua-platform-version が含まれています。

(4) 再度 Default.aspx を要求

(4) 再度 Default.aspx を要求

ブラウザから再度 Default.aspx を要求してみます。画像の赤枠の通り Web サーバーからの応答ヘッダには Accept-CH と Critical-CH が含まれていますが、、上の (2) のように再度要求が出ることはありません。


ASP.NET の C# のコードでブラウザからの要求ヘッダに含まれる Sec-CH-UA-Platform および Sec-CH-UA-Platform-Version の情報を取得するには以下のようにします。

string platform = Request.ServerVariables["HTTP_SEC_CH_UA_PLATFORM"];
string version = Request.ServerVariables["HTTP_SEC_CH_UA_PLATFORM_VERSION"];

上のコードで、platform, version に取得された文字列はダブルクォーテーション " で囲まれるので注意してください。

Tags: ,

ASP.NET

MVC5 での Dependency Injection

by WebSurfer 2023年4月15日 16:11

.NET Framework の ASP.NET MVC5 アプリで Microsoft.Extensions.DependencyInjection 名前空間にあるクラスを利用して Dependency Injection (DI) 機能を実装してみましたので、忘れないように備忘録として残しておきます。

MVC5 で Dependency Injection

ターゲットフレームワークが .NET Framework 4.6.1 以降であれば ASP.NET Core で DI に使われている Microsoft.Extensions.DependencyInjection 名前空間にあるクラス類を利用できるそうなので試してみた次第です。

参考にしたのはググって探して GitHub に見つかった ASP.NET MVC and ServiceCollection sample という記事です。その記事のコードをほぼそのまま Global.asax.cs にコピーすれば必要最低限の DI 機能が実装できます。

ベースとした ASP.NET MVC5 アプリのプロジェクトは Visual Studio 2022 のテンプレートを使ってターゲットフレームワークを .NET Framework 4.8 として作成したものです。

プロジェクトの作成

そのプロジェクトに NuGet から Microsoft.Extensions.DependencyInjection をインストールします。この記事を書いた時点での最新版 7.0.0 をインストールしました。(他に関係パッケージが 4 つ同時に自動的にインストールされます)

NuGet パッケージ

自動生成された Global.asax.cs を開き以下のように書き換えます。参考にした記事 ASP.NET MVC and ServiceCollection sample のコードほぼそのままです。自分なりに調べていろいろ分かったことをコメントとして追記してあります。

using System;
using System.Collections.Generic;
using System.Web;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using Microsoft.Extensions.DependencyInjection;
using Mvc5DependencyInjection.Controllers;
using Mvc5DependencyInjection.Repositories;

// 下の設定により Application_Start より前に InitModule メソッド
// が実行され HTTP モジュール ServiceScopeModule が登録される
[assembly: PreApplicationStartMethod(
    typeof(Mvc5DependencyInjection.MvcApplication), "InitModule")]

namespace Mvc5DependencyInjection
{
    public class MvcApplication : System.Web.HttpApplication
    {
        public static void InitModule()
        {
            // HTTP モジュール ServiceScopeModule を登録
            RegisterModule(typeof(ServiceScopeModule));
        }

        protected void Application_Start()
        {
            // DI コンテナの作成
            var services = new ServiceCollection();

            // DI コンテナにサービスを登録する。
            // ConfigureServices メソッドは下のコード参照
            ConfigureServices(services);

            // サービスプロバイダの作成
            ServiceProvider provider = services.BuildServiceProvider();

            // HTTP モジュール ServiceScopeModule に上の provider を渡し、
            // モジュール内でサービスプロバイダを利用できるようにする
            ServiceScopeModule.SetServiceProvider(provider);

            // 下の 4 行は Global.asax.cs にもともと含まれていたもの
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            // SetResolver メソッドは Microsoft のドキュメントによると
            // "Provides a registration point for dependency resolvers,
            // using the specified dependency resolver interface." との
            // こと。要するに、これにより MVC5 アプリで DI が実現できる
            // ようになるらしい
            var resolver = new ServiceProviderDependencyResolver();
            DependencyResolver.SetResolver(resolver);
        }

        // DI コンテナにサービスを登録するメソッド。
        // Controller については、ASP.NET Core では AddController,
        // AddMvc, AddControllersWithViews, AddRazorPages などを使うと
        // アセンブリをスキャンして全てを DI コンテナに登録するという
        // ことが行われるようだが、それはこのサンプルでは未実装。
        // すべて以下のメソッド内で登録する。
        private void ConfigureServices(IServiceCollection services)
        {
            services.AddScoped<ScopedThing>();
            services.AddTransient<HomeController>();
        }
    }

    // このサンプルではこのオブジェクトを HomeController に Inject
    public class ScopedThing : IDisposable
    {
        public ScopedThing()
        {
        }

        // Home/Index にてメッセージを取得できるように追加
        public string GetMessage()
        {
            return "Message form ScopedThing";
        }


        public void Dispose()
        {
        }
    }

    // HTTP モジュール
    // BeginRequest で IServiceScope を取得して HttpContext.Items
    // に保持する。EndRequest で IServiceScope を Dispose すること
    // により登録されたサービス (AddSingleton で登録されたものは除
    // く) は Dispose される
    internal class ServiceScopeModule : IHttpModule
    {
        private static ServiceProvider _serviceProvider;

        // インターフェイスに定義されているので実装が必要
        public void Dispose() { }

        public void Init(HttpApplication context)
        {
            context.BeginRequest += Context_BeginRequest;
            context.EndRequest += Context_EndRequest;
        }

        // IServiceScope オブジェクトを HttpContext.Items に保持
        private void Context_BeginRequest(object sender, EventArgs e)
        {
            var context = ((HttpApplication)sender).Context;
            context.Items[typeof(IServiceScope)] = 
                _serviceProvider.CreateScope();
        }

        // IServiceScope オブジェクトを Dispose する
        private void Context_EndRequest(object sender, EventArgs e)
        {
            var context = ((HttpApplication)sender).Context;
            if (context.Items[typeof(IServiceScope)] 
                is IServiceScope scope)
            {
                scope.Dispose();
            }
        }

        // Application_Start でサービスプロバイダが渡される
        public static void SetServiceProvider(
            ServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }
    }

    // BeginRequest で IServiceScope が HttpContext.Items に保持され
    // ているので、それを使って DI に登録されたサービスを取得できる
    internal class ServiceProviderDependencyResolver 
                   : IDependencyResolver
    {
        public object GetService(Type serviceType)
        {
            if (HttpContext.Current?.Items[typeof(IServiceScope)] 
                is IServiceScope scope)
            {
                return scope.ServiceProvider.GetService(serviceType);
            }

            throw new InvalidOperationException(
                                    "IServiceScope not provided");
        }

        public IEnumerable<object> GetServices(Type serviceType)
        {
            if (HttpContext.Current?.Items[typeof(IServiceScope)] 
                is IServiceScope scope)
            {
                return scope.ServiceProvider.GetServices(serviceType);
            }

            throw new InvalidOperationException(
                                      "IServiceScope not provided");
        }
    }
}

上の ConfigureServices メソッドに Controller とそれが依存するクラスを登録すれば DI 機能は働くようになります。

この記事では HomeController と ScopedThing を登録しています。自動生成された HomeController のコードに手を加えて、以下のようにコンストラクタ経由で ScopedThing を受け取れるようにすれば、クライアントからの要求を受けて HomeController が呼び出されたときに DI 機能により ScopedThing のインスタンスへの参照がコンストラクタの引数に渡されます。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace Mvc5DependencyInjection.Controllers
{
    public class HomeController : Controller
    {
        private readonly ScopedThing _scopedThing;

        public HomeController(ScopedThing scopedThing)
        {
            this._scopedThing = scopedThing;
        }

        public ActionResult Index()
        {
            string msg = _scopedThing.GetMessage();
            ViewBag.Msg = msg;
            return View();
        }        
    }
}

Index アクションメソッドに書いたように、Inject された ScopedThing インスタンスの GetMessage メソッドで "Message form ScopedThing" という文字列を取得できます。

処理が終わると HttpApplication.EndRequest イベントが発生するので、HTTP モジュール ServiceScopeModule により ScopedThing の Dispose メソッドが自動的に呼ばれます。


先の記事「ASP.NET MVC5 で Autofac.Mvc5 使って DI」と同様に、下の画像のようなリポジトリパターンを実装し、「Entity データモデル」を「本番用クラス」に、「本番用クラス」を「コントローラークラス」に Inject することもできます。

リポジトリパターン

具体例を書くと、まず、先の記事と同様に (1) Visual Studio の ADO.NET Entity Data Model ウィザードで Entity Data Model (EDM) を作成し、(2) それをベースにスキャフォールディング機能を使って CRUD 用の Controller を自動生成させ、(3) Controller から SQL Server にアクセスして操作するコードをリポジトリクラスに切り出し、(4) Controller にリポジトリクラスを Inject できるようにコンストラクタを追加します。その手順の詳しい説明とコード例は先の記事にありますので見てください。

その後、作成した EDM のコンテキストクラス、リポジトリクラス、コントローラークラス を上の Global.asax.cs の ConfigureServices メソッドで DI コンテナに登録します。以下のコードで「// 追加」とコメントした下の 3 行がそれです。それだけで DI 機能が働くようになります。

private void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<ScopedThing>();
    services.AddTransient<HomeController>();

    // 追加
    services.AddScoped<NORTHWINDEntities>();
    services.AddScoped<IProductRepository, ProductRepository>();
    services.AddTransient<ProductsController>();
}

上の画像の「コントローラークラス」が ProductsController に、「インターフェイス」が IProductRepository に、「本番用クラス」が ProductRepository に、「Entity データモデル」が NORTHWINDEntities に該当します。

ブラウザから Prodtcuts/Index を要求すると、ProductsController が依存する ProductRepository、ProductRepository が依存する NORTHWINDEntities は DI 機能により自動的に Inject され、SQL Server からデータを取得して結果が表示されます。それがこの記事の一番上の画像です。

Tags: , ,

MVC

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年3月  >>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar