🛟

[ASP.NET Core, Blazor] CSP を設定したら Hot Reload が効かなくなった

2024/07/06に公開

ASP.NET Core を使った C# による Web アプリケーション開発、とりわけ、Blazor アプリケーション開発時のお話です。

まずは CSP (Content Security Policy) の話

さて、Web サイト/アプリケーションの構築において、"CSP (Content Security Policy)" というのを設定することがあります。これは、「この Web ページ上では、様々な CSS スタイルシートや JavaScript ファイル、フォントファイルなどなど、いろいろなコンテンツを読み込みますが、でも、読み込んでいいのは、カクカクシカジカの条件を満たしたものだけにしてね」という、Web ブラウザに対する "指示書" です。設定箇所は HTML の meta タグ、または、HTTP 応答ヘッダー内に記載します。HTML の meta タグに指定する例を以下に示します。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';" />
    ...

上記の設定だと、「基本的に、このページで読み込んでいいコンテンツは、この HTML と同一オリジンだけね。でも、JavaScript は eval 実行は許してあげて。あと、CSS スタイル定義のインライン指定も許してあげて。」という指示、CSP の設定になります。

このように CSP を指定しておくと、何らかの隙を突かれて、マルウェア的な外部の JavaScript ファイルを注入されそうになっても、ブラウザが「あ、そこからの JavaScript ファイルの読み込みは、CSP で許可されてないので、中止するわ」となり、危険を回避できる、というわけです。

CSP について説明がされている MDN へのリンクを以下に掲載しておきます。

https://developer.mozilla.org/ja/docs/Web/HTTP/CSP

ASP.NET Core アプリケーションに CSP 適用したら Hot Reload が効かなくなった

さて上記の CSP は、当然のことながら、C# で Web アプリケーションを開発する場合、すなわち、ASP.NET Core ないしはその上に構築される Blazor でも有用です。ということで、とある Blazor WebAssembly アプリケーションの wwwroot/index.html に、前述の CSP 指定の meta タグを追記してみました。

その上で dotnet watch コマンドで実行してみると、ひとまずアプリは起動するものの、Web ブラウザの開発者ツールでコンソール表示を確認してみると、以下のように何やらエラーが報告されています。

Refused to connect to 'wss://localhost:53746/' because it violates the following Content Security Policy directive: "connect-src 'self'"

そしてこの状態だと、Hot Reload が効かなくなっていました。

Hot Reload を実現するためのソケット通信ができなくなっていたのが原因

この問題の原因は、ブラウザの開発者コンソールに表示されていたメッセージから推測できるように、dotnet watch コマンドによる Hot Reload を実現するための、dotnet コマンドと Web ブラウザ間の通信に使われる WebSocket 接続が、CSP で指示された条件 (この場合は 'self'、つまり同一オリジンのみ接続可能) に合致しなかったことから、接続が拒否されてしまたからです。

Hot Reload を使わないのであれば、別にこのままでも構わないのですが、Hot Reload を使いたければ、CSP の条件を緩和せざるをえません。具体的には、以下のように、connect-src の指定を追加して、'selft に加えて、ws://localhost:* というように、dotnet コマンドとの WebSocket 通信を許可してあげるとよいです。

<meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* " />

dotnet CLI で開発している場合は以上なのですが、Windows 上で Visual Studio を使って開発している場合は、もう一点、注意が必要です。Visual Studio と Web ブラウザとの接続伊能である Browser Link のために、http://localhost:* の接続も許可してあげる必要があるようなのです。自分の場合、以下のように Web ブラウザの開発者コンソールにエラーが表示され、Hot Reload がうまく効かず、何か書き換えると必ず再ビルドが実行されるようになってしまいました。

browserLink:21 
 Refused to connect to 'http://localhost:55352/9b0efcd…/browserLinkSignalR/…ationData.browserId&browserId=8efb-052c&clientProtocol=1.3&_=1720165089320' because it violates the following Content Security Policy directive: "connect-src 'self' ws://localhost:*".

以上のように、Visual Studio 上での開発の場合は、ws://localhost:* に加えてさらに、http://localhost:*connect-src も許可してあげる必要があるようです。

<meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:*" />

これら Hot Reload のためのポリシーは残したままでいいの?

以上のように、ws://localhost:* および http://localhost:*connect-src ポリシーで許可してあげることで、開発時の Hot Reload が機能するようになりますが、さて、このような CSP 構成のまま、このアプリケーションをリリース環境に配置してもいいものでしょうか。

結論としては、よくない、ですね、はい。やはり、リリース環境で必須ではないコンテンツ読み込み許可があると、少なくとも理論上は、何らかの脅威に対するリスクが高まります。

なので、何かしらの手段を用いて、リリース環境では、connect-src ポリシーには
ws://localhost:* および http://localhost:* は含まれないようにしたい
ところです。

開発環境でのみ、Hot Reload のためのポリシーを追加する方法

サーバー側でページ生成する場合は、ポリシー文字列を動的に構築しやすいのでやりやすいでしょう。例えば .NET 8 の Blazor Web App の場合、Components/App.razor 内で、そのような実行時の動的な判定とポリシー文字列の構築を行えると思います。

IWebHostEnvironment に開発環境かどうか尋ねる

ひとつの案として、IWebHostEnvironment から環境名を判定してポリシー文字列を構築することができるでしょう。

Components/App.razor
@page "/"
@inject IWebHostEnvironment WebHostEnvironment

<!DOCTYPE html>
<html lang="en">
<head>
  // フィールド変数に設定されたポリシー文字列を meta タグに掲載する
  <meta http-equiv="content-security-policy" content="@_ContentSecurityPolicy" />
...
@code {
  // リリース環境で必要最低限のポリシー文字列を既定とする
  private string _ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'";

  protected override void OnInitialized()
  {
    // 開発環境の場合は、ポリシー文字列に Hot Reload 用の緩和ポリシーを追記する
    if (WebHostEnvironment.IsDevelopment())
    {
      _ContentSecurityPolicy += "; connect-src 'self' ws://localhost:* http://localhost:*";
    }
  }
  ...

アプリケーション構成 (appsettings.json) を利用する

あるいは、アプリケーション構成 (appsettings.json など) が環境ごとに上書き (例えば appsettings.Development.json) できることを応用して、アプリケーション構成としてポリシー文字列を設定する方法もよいかもです。

リリース環境用のポリシー文字列を、appsettings.json 内に以下のように "ContentSecurityPolicy" などといったキーで設定しておき、

appsettings.json
{
  "ContentSecurityPolicy": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'",
  ...

開発環境用のポリシー文字列を appsettings.Developemnt.json にて以下のように定義して、開発時のポリシー文字列を上書きします。

appsettings.Development.json
{
  "ContentSecurityPolicy": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:*",
  ...

あとはこのアプリケーション構成を参照して Content Security Policy として以下のように適用できるでしょう。

Components/App.razor
@page "/"
@inject IConfiguration Configuration

<!DOCTYPE html>
<html lang="en">
<head>
  <meta http-equiv="content-security-policy" content="@(this.Configuration.GetValue(key: "ContentSecurityPolicy", defaultValue: ""))" />
  ...

もちろん、アプリケーション実行時のコマンドライン引数であったり、環境変数からも、これらアプリケーション構成を制御可能です。

なお、アプリケーション構成で Content Security Policy を制御するこの方法は "Hot Reload を有効にするための差分" だけを記述できず、リリース環境用と開発環境用の、各々の完全なポリシー文字列をそれぞれの .json ファイルや環境変数等に指定する必要があるため、冗長な感じもあります。リリース環境用のポリシー文字列を変更したら、開発環境用のポリシー文字列設定にもその変更を重ねて反映させる必要が発生します。いっぽうで、すでに何らかの事情により、リリース環境でも connect-src ポリシーを構成している場合は、そもそも "差分として connect-src を足す" という方法は採れませんので、その場合はこの冗長問題があったとしても、アプリケーション構成方式が有用かもしれません。

HTTP 応答ヘッダーで指定してもよい

なお、ここまでの例は、ページの meta タグでの指定で実装してきましたが、もちろん、HTTP 応答ヘッダで指定してもよいです。

Components/App.razor
...
@code {
  [CascadingParameter]
  public HttpContext? HttpContext { get; set; }

  protected override void OnInitialized()
  {
    // リリース環境で必要最低限のポリシー文字列を既定とする
    var contentSecurityPolicy = = "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'";

    // 開発環境の場合は、ポリシー文字列に Hot Reload 用の緩和ポリシーを追記する
    if (WebHostEnvironment.IsDevelopment())
    {
      contentSecurityPolicy += "; connect-src 'self' ws://localhost:* http://localhost:*";
    }

    // ポリシー文字列を HTTP 応答ヘッダに設定する
    if (HttpContext is not null)
    {
      HttpContext.Response.Headers.Add("Content-Security-Policy", contentSecurityPolicy);
    }
    ...

静的サイトの場合は発行時にフォールバックページを書き換える

Blazor WebAssembly Stanalone なアプリを、GitHub Pages などの静的サイトに配置する、といった場合は、サーバー側で実行時に動的に判定・CSP を動的に適用できないと思われます。この場合は、当該アプリを発行 (dotnet publish) 後に、発行されたあとのフォールバックページ (index.html など) 内の meta タグに指定されているポリシー文字列を書き換えるほかないように思われます。

GitHub Actions など CI/CD パイプラインから発行している場合は、以下のように sed コマンドを用いるなどして、フォールバックページ内のポリシー文字列を置換・更新することになるでしょう。

sed -i "s|<meta http-equiv=\"content-security-policy\" content=\"default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:*\"/>|<meta http-equiv=\"content-security-policy\" content=\"default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'\"/>|" ./index.html

おわりに

クロスサイトスクリプティング (XSS) などの脅威に対するリスクを少しでも軽減できるよう、CSP (Content Security Policy) を設定しておくのはよいプラクティスと思います。

いっぽうで、開発時に Hot Reload を効かせるためには、dotnet CLI や Visual Studio などの開発ツールと Web ブラウザとが通信するための WebSocket 等の接続を CSP で許容してあげる必要があります。

ただしそれら接続のためのポリシーは、開発時のみ必要なものなので、安全のためにも、リリース環境には含まれないようにするべきです。そのため、リリース環境と開発環境とで、ポリシー文字列を使い分けできるように実装するとよいでしょう。

Discussion