鷲ノ巣

C# とか PowerShell とか。当ブログの記事は(特に公開直後は)頻繁に改定される場合があることをご了承ください。

ダメなコードの書き方 (ex. ASP.NET Core ミドルウェアの非同期初期化)

はじめに

本記事は C# Advent Calendar 2021 その2の 18 日目の記事です。

qiita.com

なお、記事を書きながら反省したのですが、以下に掲載するサンプルコードは、いいところがほとんどありません。真似しないでください。
実装しちゃったコードは週明けに直します。
ただまぁ、今更 Advent Calendar に穴を開けるわけにもいきませんので、ここは最後まで書ききってしまうことにしましょう。

追記

改善版の記事を文末にリンクしていますのでご覧ください。

ASP.NET Core アプリケーションのスタートアップ シーケンス

ASP.NET Core のアプリケーションの初期化段階をざっくりと言うと

  1. Configuration や Logging の準備
  2. Dependency Injection の設定
  3. リクエスト パイプラインの構成

のような段階に分けられると思います。
ASP.NET Core 6 の Minimal API と呼ばれるスタイルではまた事情が違うのですが、ASP.NET Core 5 までのスタイルで言うと、各ステージは

  1. ProgramMain メソッドおよびそこから直接呼ばれるコード
  2. StartupConfigureServices メソッド
  3. StartupConfigure メソッド

に対応します。

アプリケーションが動き出してしまえば、あとのリクエストの処理は、MVC なら Controller、そうでなければエンドポイントなどで行うのですが、そこは完全に非同期メソッドで書くことができます。
しかし、ConfigureServices とか Configure とかは、同期的に書かなければなりません。

とはいえ、スタートアップ時に構成をファイルやデータベース、ネットワーク等から読み取りたいこともありますよね。
特に、HttpClientJSON とかを読み取って処理したいとなると、基本的には非同期メソッドしか用意されていません。
かといって、Task.Result とかで同期的に解決するのも、なんかやだ。
じゃあどうするよ、というお話です。

まぁ、ここはリクエストの処理が始まる前なので、そこまでブロッキングに神経質になる必要はありませんし、ASP.NET Core では Task.Result で同期的に待機しても、基本的には大丈夫なはずなのですが。

TL; DR

記事はクッソ長くなるので、具体的にどうしたのかという話を端的に言うと

  • リクエスト パイプラインは非同期に書けるんだから、最初にリクエストが来た時に非同期初期化すればいいじゃん
  • AsyncLazy を使ってみた

みたいな話です。

もうちょっと具体的に

Web アプリケーションを公開する場合、だいたい、その前段にはいくつかのプロキシが挟まるものです。
すると、アプリケーションに対して直接 HTTP リクエストをしてくるクライアントはプロキシになりますが、しかし、実際にリクエストを投げてきたエンドユーザーの情報を取得したいことはままあるものです。
そういう場合、ASP.NET Core では、ForwardedHeadersMiddleware というやつを使います。
このミドルウェアを適切に構成するためには、プロキシの IP アドレスの情報が必要になります。

ところで私は、お仕事では AWS を使っています。
AWS だと CloudFront とか Application Load Balancer (ALB) といったプロキシがあり、お仕事でもこれらを使用しています。
そこで、CloudFront 等が使用する IP アドレスの情報を入手せねばなりません。
これは、以下のページで公開されています。

docs.aws.amazon.com

で、最初は、ip-ranges.json の内容を appsettings.json に転記していたのですが、それだと、IP アドレス情報が変わったときに追従漏れが起きそうです。
なので、アプリケーションのスタートアップ時に、この JSON を読み込んで、自動的に最新の情報で構成されるようにしようと思ったわけです。

HTTP で公開されている JSON なので、HttpClient.GetFromJsonAsync で読み取るのが簡単です。
まぁ、当然のように非同期メソッドなわけですね。
ただ、スタートアップのメソッドには ConfigureAsync とかはありませんから、非同期メソッドは普通には書けません。さてどうしましょう。

実際にやったこと

ざっくり言いますと、ForwardedHeadersMiddleware を直接使うのではなく、それを包含したミドルウェアを書きました。
で、最初のリクエストが来たタイミングで遅延初期化をしています。
一回だけ初期化処理が走るように AsyncLazy を使いました。

疑似コードを載せるとこんな感じです。
IpRangesClientAWSip-ranges.json を取得してパースして返すクライアントで、AddHttpClient<T> で DI に登録しておきます。
もちろん JsonSerializer の Source Generator は有効にしてあります。

public class AwsForwardedHeadersMiddleware
{
    private readonly RequestDelegate _next;

    private readonly AsyncLazy<ForwardedHeadersMiddleware> _middleware;

    public AwsForwardedHeadersMiddleware(
        RequestDelegate next,
        ILoggerFactory loggerFactory,
        IpRangesClient client,
        IOptions<AwsForwardedHeadersOptions> options)
    {
        this._next = next;

        this._middleware = new(async () => {
            var ipRanges = await ipRangesClient.GetIpRanges().ConfigureAwait(false);

            var forwardedHeadersOptions = new ForwardedHeadersOptions();
            // ipRanges の情報を使って forwardedHeadersOptions を構成する

            var middleware = new ForwardedHeadersMiddleware(
                next,
                loggerFactory,
                Options.Create(forwardedHeadersOptions));

            return middleware;
        });
    }

    public async Task Invoke(
        HttpContext context)
    {
        var middleware = await this._middleware.GetValueAsync(context.RequestAborted).ConfigureAwait(false);
        middleware.ApplyForwarders(context);

        await this._next(context).ConfigureAwait(false);
    }
}

参考リンク集

以上でやったことは大体説明し終わりました。ここからは参考資料集になりますが、記事の分量で言うと、ここからが本番です。

やはりスタートアップで非同期処理をしたいという要望はあるようで、GitHub にも Issue が立っています。
マイルストーンとしては、いまのところ .NET 7 となっていますが、果たして実現されるものでしょうか?
github.com

ConfigureServicesConfigure といったメソッドは非同期処理をサポートしませんが、その前にある async Main では普通に非同期処理が書けるので、そこで解決しておくという手もあります。
ただ、Main に直接書いたものは、ASP.NET Core の管轄外のコードとなるため、結合テストをする際には注意が必要になります。
zenn.dev

メジャーな DI コンテナの一つである SimpleInjector の開発陣は、DI を非同期的に構成することを良く思っていないようです。
github.com

HttpClient には .NET 5.0 から Send という同期版メソッドが追加されました。
これを使えば同期的に HTTP アクセスができます。
ただ、Send は低レベルなメソッドであり、GetAsync に対する同期版 Get メソッド等は用意されていません。
Get するだけでなく JSON のデシリアライズまでしてくれる GetFromJsonAsync にも当然、同期版はありません。
Send を使う場合、こうした層を再実装する必要があるため、今回はこの方法は取りませんでした。
docs.microsoft.com

ForwardedHeaders の詳細

今回のシステム構成を抜粋すると、こんな感じになっています。よくある構成だと思います。
f:id:aetos382:20211218024052p:plain

今回やりたかったのは以下の2点です。

  • エンドユーザーの IP アドレスによってアクセス制限をかける
  • エンドユーザーがアクセスしている URL スキームを取得する

ASP.NET Core では、接続元の IP アドレスは HttpContext.Connection.RemoteIpAddress で、URL スキームは HttpRequest.Scheme で得られます。

前述したように、ECS 上で稼働しているアプリケーションから見ると、直接接続してきているクライアントはプロキシであり、エンドユーザーはその向こう側にいます。その IP アドレスをどうやって取得するのかというのが一つ。

また、今回、エンドユーザーは CloudFront に HTTPS で接続しますが、そこから内部は HTTP で通信しています。もちろんアプリケーションへのリクエストも HTTP で行われます。
アプリケーションでリダイレクト用の URL を生成する場合などは、現在のリクエストを基準として生成することが多いのですが、そのままだと URL のスキームは HTTP になってしまいます。
やはりここでも、プロキシの向こう側にいるエンドユーザーが使っている URL スキームの情報を取得する必要があるわけです。

ここで使えるのが、X-Forwarded-ForX-Forwarded-Proto といった HTTP リクエスト ヘッダーです。それぞれ、エンドユーザーの IP アドレスと、接続に使用しているプロトコル(URL スキーム)を伝達してくれます。
developer.mozilla.org
developer.mozilla.org

なお、これらのファミリーとしてもう一つ、X-Forwarded-Host というヘッダーもありますが、今回のアプリではこのヘッダーは気にしていないので、本記事中でも取り上げていません。

ごめん

以下の画像中、Forwarded であるべきところが Forward になっています。画像を直すのは面倒なので、このままにさせてください。気が向いたら直します。

X-Forwarded-For

途中のプロキシがどのように振舞うかを追いかけることで、これらのヘッダーの挙動を理解しましょう。
まず、最初のプロキシである CloudFront は、エンドユーザーの IP アドレスを知ることができます。
f:id:aetos382:20211218030633p:plain

次のロードバランサは、接続元の IP アドレスとしては CloudFront のものを受け取りますが、同時に、CloudFront が X-Forwarded-For ヘッダーで接続元の IP アドレスを伝えます。
f:id:aetos382:20211218031032p:plain

アプリケーションは、接続元の IP アドレスとしてはロードバランサ―のものを受け取りますが、同時に、ロードバランサ―が X-Forwarded-For ヘッダーに自身の接続元の IP アドレスを付加して渡します。
f:id:aetos382:20211218031503p:plain

このようなリクエストを受けて、アプリケーションは、直接の接続元 IP アドレスの代わりに、X-Forwarded-For ヘッダーの左端にある IP アドレスを、エンドユーザーのものとして得ることができるわけです。

さて、どうしてこの処理に、AWS が提供するプロキシの IP アドレスの情報が必要なのでしょうか。
それは、悪意あるクライアントがヘッダーを偽装するかもしれないからです。
たとえば、アプリケーションでは、233.252.0.0/24 という範囲の IP アドレスからのアクセスのみを許可したいとしましょう。
クライアントがリクエストに細工をしなければ、このアクセスは拒否されますね。
f:id:aetos382:20211218032921p:plain

このことを知った、しかし自身はこの範囲内の IP アドレスを持たないクライアントが、プロキシの振りをして X-Forwarded-For ヘッダーをつけてリクエストをするとどうなるでしょうか。
f:id:aetos382:20211218032518p:plain

途中のステップは省きますが、アプリケーションが受け取るリクエストはこのようなものになります。
ここで単純に X-Forwarded-For ヘッダーの左端の値をエンドユーザーの IP アドレスだと思ってしまうと、悪意あるユーザーにアクセスを許可してしまうことになるわけです。
f:id:aetos382:20211218033404p:plain

そこで、アプリケーションは 信頼できるプロキシの IP アドレス の情報を持つ必要があるのです。
X-Forwarded-For ヘッダーの値を右から順にみていくと、203.0.113.10 は既知のプロキシのアドレスですが、192.0.2.78 はそうではありません。
つまり、右から見ていって、初めて出てきた知らないアドレスが、エンドユーザーの IP アドレスである(そこから先は疑わしいので無視する) という処理が必要なのです。
X-Forwarded-For ヘッダーに 192.0.2.78 という IP アドレスをつけたのは信頼できるプロキシである CloudFront なので、この情報は疑う必要がありません。
f:id:aetos382:20211218034256p:plain

ASP.NET Core の場合、以下のように構成することで、上記のような挙動をさせることができます。

var options = new ForwardedHeadersOptions {
    ForwardedHeaders = ForwardedHeaders.XForwardedFor,
    ForwardLimit = null
};

options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("198.51.100.0"), 24));
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("203.0.113.0"), 24));

app.UseForwardedHeaders(options);

こうすると、ForwardedHeadersMiddlewareHttpContext.Connection.RemoteIpAddress を、直接の接続元である 198.51.100.6 ではなく、エンドユーザーの IP アドレスである 192.0.2.78 に書き換えてくれるのです。便利。

X-Forwarded-Proto

このヘッダーは、最初にクライアントからのリクエストを受け付けたプロキシが、その URL スキームを保存するために付けるヘッダーです。
これを見ることで、アプリケーションは、エンドユーザーのリクエストが HTTPS で行われたことを知ることができます。
f:id:aetos382:20211218035732p:plain

たとえば、API で何らかのリソースを作成したときに、201 Created レスポンスとともに、そのリソースの URL を返すために、ControllerBase.CreatedAtAction を使います。
このメソッドがリソースの URL を生成する際には、現在のリクエストの情報が使われます。スキームは HttpRequest.Scheme から取得しますが、何もしなければ、アプリケーションに対する直接のリクエストのスキーム、つまり HTTP が使われてしまいます。
そして http://api.example.com/resource/xxx というような URL を返したところで、CloudFront が HTTP アクセスを受け付けていなければ、クライアントはそれにアクセスすることができません。
そこで、エンドユーザーが実際に使用した URL スキームを伝えてもらうことで、エンドユーザーがアクセスできる URL を生成して返すことができるのです。

こちらの場合、エンドユーザーにはヘッダーを偽装するメリットはありませんので、難しいことを考える必要はありません。

ところで、一般的にはこのヘッダー名は X-Forwarded-Proto なのですが、CloudFront の場合は CloudFront-Forwarded-Proto という名前で送ってきます。
これを正しく解釈するためには、ForwardedHeadersMiddleware を以下のように構成してやる必要があります。

var options = new ForwardedHeadersOptions {
    ForwardedHeaders = ForwardedHeaders.XForwardedProto,
    ForwardedProtoHeaderName = "CloudFront-Forwarded-Proto"
};

app.UseForwardedHeaders(options);

こうすると、ForwardedHeadersMiddlewareHttpRequest.SchemeHTTPS に書き換えてくれます。

先の X-Forwarded-For の例とまとめると、こうなります。

var options = new ForwardedHeadersOptions {
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
    ForwardLimit = null,
    ForwardedProtoHeaderName = "CloudFront-Forwarded-Proto"
};

options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("198.51.100.0"), 24));
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("203.0.113.0"), 24));

app.UseForwardedHeaders(options);

改善すべき点

冒頭に掲載したようなコードで、最初のリクエスト時に一度だけ JSON をパースするだけだと、アプリケーションのスタートアップ時に一度だけ読み込まれるだけです。その後、アプリケーションが再起動されない限り、IP アドレスの情報が更新されません。
もしアプリケーションが長期間動き続けてしまうと、プロキシの IP アドレス情報が増減したことを検知できないかもしれません。
そのため、1日1回くらいは、何らかの方法で JSON を再読み込みすべきでしょう。
その方法は週明けに考えることにします。

追記

改善版を書きました。
tech.blog.aerie.jp