結論
ASP.NET Coreでローカライズする時には以下の3つのいずれかを使う。
このうちIHtmlLocalizerとIViewLocalizerはHTMLのタグを含んだ文字列をローカライズするため、パラメータ部以外はHTMLエスケープされない。また、キーがない時にはそのキーをそのままHTMLエスケープせずに出力する。
そのため、外部からのパラメータをそのままローカライザに渡すような使い方をしているとXSS(クロスサイトスクリプティング)を食らうことになるので注意が必要。
実験環境
macOS 10.16.6
ASP.NET Core 3.1
dotnet new mvc コマンドで作成したMVCのWebアプリ
Razorは自動的に変数出力をエスケープしてくれる
Views/Home/Index.cshtmlを以下のように書く。
@{
ViewData["Title"] = "Home Page";
var textNotNeedEscaped = "エスケープ不要";
var textNeedEscaped = "<script>alert(\"alert\");</script>";
}
<div class="text-center">
<p>@textNotNeedEscaped</p>
<p>@textNeedEscaped</p>
</div>
起動してページにアクセスすると、問題なく表示される。
出力された該当部分のHTMLは適切にエスケープされていることを確認。(ついでに日本語もエスケープされている。非アスキー文字はエスケープ対象ということか)
<div class="text-center">
<p>エスケープ不要</p>
<p><script>alert("alert");</script></p>
</div>
Razor(ASPのビューエンジン)の「@」での変数出力は自動的にHTMLをエスケープしてくれて、特に意識しなくてもXSSを起こしにくくなっている。
なお、あえてエスケープせずに出力する場合は、Html.Rawメソッドを使用すればよい。
@{
ViewData["Title"] = "Home Page";
var textNotNeedEscaped = "エスケープ不要";
var textNeedEscaped = "<script>alert(\"alert\");</script>";
}
<div class="text-center">
<p>@textNotNeedEscaped</p>
<p>@textNeedEscaped</p>
<p>@Html.Raw(textNeedEscaped)</p>
</div>
これだと普通にalert出力される。これは意識しないと書かないので危険は少ないだろう。
該当部分のHTMLはこうなる。当然ながらスクリプトタグがそのまま出力されている。
<div class="text-center">
<p>エスケープ不要</p>
<p><script>alert("alert");</script></p>
<p><script>alert("alert");</script></p>
</div>
ビューのローカライズをする場合
ローカライズするとどうなるかを見てみる。ローカライズというのは多言語対応のため、キーとなる文字列を様々な言語に置き換える処理のことで、ASP.NET Coreではそのための仕組みが用意されている。
ASP.NET Coreのローカライズについて以下のページを参照。
https://docs.microsoft.com/ja-jp/aspnet/core/fundamentals/localization?view=aspnetcore-3.1
このうち、まずビューのローカライズを使ってみる。ビューのローカライズで使うのはIViewLocalizerであり、ビューのファイル名と同じリソースファイルを見つけてローカライズする、という仕組み。
ローカライズの準備として、Startup.csを以下のように変更する必要がある。
ローカライズのフォルダはResoucesとする、ビューのローカライズをする、ローカライズは日本語と英語でデフォルトは日本語、クエリ文字列とクッキーでローカライズする、といった設定。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Localizer2
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddControllersWithViews().AddViewLocalization();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
var supportedCultures = new[] {
System.Globalization.CultureInfo.GetCultureInfo("ja"),
System.Globalization.CultureInfo.GetCultureInfo("en")
};
app.UseRequestLocalization(new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture(System.Globalization.CultureInfo.GetCultureInfo("ja")),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures,
RequestCultureProviders = new List<IRequestCultureProvider>
{
new QueryStringRequestCultureProvider(),
new CookieRequestCultureProvider()
}
});
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
}
Index.cshtmlを以下のようにする。
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<p>@Localizer["textNotNeedEscaped"]</p>
</div>
IViewLocalizerはResourcesフォルダ内でビューと同じ位置にあるリソースファイルを見つけて使用するため、Resourcesフォルダを以下のような構造にする。
Resources
└── Views
└── Home
├── Index.en.resx
└── Index.ja.resx
各リソースファイルはVisual Studioなどで生成できるが、実態はただのXMLファイルなので適当に作成できる。とりあえずこんな感じで作っておく。
Index.ja.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="textNotNeedEscaped" xml:space="preserve">
<value>エスケープ不要</value>
</data>
</root>
Index.en.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="textNotNeedEscaped" xml:space="preserve">
<value>This text does not need to be escaped.</value>
</data>
</root>
画面を表示してみると、ちゃんとローカライズされていることがわかる。
次はエスケープが必要な文字列の確認。
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="textNotNeedEscaped" xml:space="preserve">
<value>エスケープ不要</value>
</data>
<data name="textNeedEscaped" xml:space="preserve">
<value><script>alert("alert");</script></value>
</data>
</root>
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<p>@Localizer["textNeedEscaped"]</p>
</div>
おっと、普通にスクリプト実行されてしまった。IViewLocalizerはIHtmlLocalizerを継承しており、IHtmlLocalizerはHTMLタグを含んだ文字列をローカライズすることが前提になっているため、タグがタグのまま出てしまう。
ローカライズの説明にも
ViewLocalizer は、IHtmlLocalizer を使用してローカライザーを実装するので、Razor は、ローカライズされた文字列を HTML エンコードしません。 IViewLocalizer は、パラメーターを HTML エンコードしますが、リソース文字列は HTML エンコードしません。
と書かれている。パラメータはHTMLエスケープされるけど、ローカライズテキスト自体にタグが入っている場合はエスケープされないということになる。
パラメータとして渡される部分はローカライズされることを確認してみる。パラメータを渡す場合のローカライズは以下のように、インデクサの第2引数以降にパラメータを書く形になる。
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="textNotNeedEscaped" xml:space="preserve">
<value>エスケープ不要</value>
</data>
<data name="textNeedEscaped" xml:space="preserve">
<value><script>alert("alert");</script></value>
</data>
<data name="yourNameIs" xml:space="preserve">
<value>あなたの名前は{0}です。</value>
</data>
</root>
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = "Home Page";
var name = "山田太郎";
}
<div class="text-center">
<p>@Localizer["yourNameIs", name]</p>
</div>
ローカライズテキストにパラメータが入ったことが確認できた。次にパラメータがHTMLエスケープされるかを確認する。
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = "Home Page";
var name = "<script>alert(\"alert\");</script>";
}
<div class="text-center">
<p>@Localizer["yourNameIs", name]</p>
</div>
こちらはちゃんとエスケープされた。ということで基本的には問題なさそう。なぜならローカライズテキストは自分で用意しているものであってその中に危険な文字列はわざわざ入れないし、外部から入ってくるであろうパラメータはちゃんとHTMLエスケープされるから。この状況ではXSSは起きない。
ローカライズのキーがパラメータとして渡される場合
問題になるケースとして、ローカライズのキー自身をパラメータとして渡す場合が考えられる。例えば以下のようにエラーコードに応じてエラーメッセージを表示する、という場合はありそう。
HomeController.cs
using Microsoft.AspNetCore.Mvc;
namespace Localizer2.Controllers
{
public class HomeController : Controller
{
public IActionResult Index(string error)
{
return View("Index", error);
}
}
}
Index.cshtml
@model string
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<p>@Localizer[Model]</p>
</div>
Index.ja.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="Err01" xml:space="preserve">
<value>ログインしてください</value>
</data>
</root>
http://localhost:5000?error=Err01 にアクセスすると、用意したエラーメッセージが表示される。
次にerrorパラメータにエスケープが必要な文字列を指定してみる。例えば、http://localhost:5000/?error=%3Cscript%3Ealert(%22alert%22)%3C/script%3E にアクセスする。
スクリプトが実行されてしまった。このようなURLをiframeの中に仕込まれたりしたらXSSが成立する。IViewLocalizerやIHtmlLocalizerはキーが存在しないとそのまま出力するので、外部から渡したパラメータをそのままローカライザに渡すとHTMLエスケープされずに出力してしまう。このような使い方はXSS対策的に避けるべきだろう。
これを避けるには、外部から指定するパラメータはローカライズのキーに渡さないのが確実だが、渡すような設計にする場合は正規のパラメータであるかを検証してから渡すようにする必要がある。
こんな感じ。
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
namespace Localizer2.Controllers
{
public class HomeController : Controller
{
private readonly List<string> Errors = new List<string> { "Err01" };
public IActionResult Index(string error)
{
if (!string.IsNullOrEmpty(error) && !Errors.Contains(error))
{
error = "";
}
return View("Index", error);
}
}
}
エラーコードとして想定していない文字列が指定された場合は空文字になるので、先ほどのようなXSSは起こらない。
というかローカライザはキーが存在しなければ空白になるかエラーになるようにしてくれないだろうか。。キーをそのまま出すことが期待されているシチュエーションがあるんだろうか。英語の場合はそのままキーを使っている?
SharedResourceを使う場合
ローカライズにはIViewLocalizerだけではなく、共通リソースファイルを用意してそれを利用する方法もあり、公式の資料にも記載されている。Resourcesフォルダにローカライズ用の共通リソースクラスを作り、同じ名前のリソースファイルを作ってDIを通して参照する。公式の資料ではSharedResourceクラスとなっているので同じ名前にするが、クラス名はなんでも良い。
Resources
├── SharedResource.cs
├── SharedResource.en.resx
└── SharedResource.ja.resx
SharedLocalizer.csはこんな感じで、中身は必要ない。
namespace Localizer2.Resources
{
public class SharedResource
{
}
}
リソースファイルの中身は先ほどと同じものとする。
ビューではこのローカライザをinjectして使う。まずはIHtmlLocalizerとして使ってみる。これはHTMLのタグをそのまま出力するもので、IViewLocalizerと同じ動作をするはず。
@model string
@using Microsoft.AspNetCore.Mvc.Localization
@inject IHtmlLocalizer<Localizer2.Resources.SharedResource> Localizer;
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<p>@Localizer[Model]</p>
</div>
さっきの対策として入れたコントローラのパラメータチェックは無くした状態で、まず想定されたエラーコードを使用する場合を見てみる。
同じようにローカライズされた。次にXSS攻撃用のURLを渡してみる。
スクリプトが実行された。これは想定通り。
次にIStringLocalizerとして使ってみる。これは名前空間がMicrosoft.Extensions.Localization
であることからもわかるように、Webのみならず一般的に使用されるローカライザと思われる。(IHtmlLocalizerの名前空間はMicrosoft.AspNetCore.Mvc.Localization
であり、Webのために使う感がある)
@model string
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Localizer2.Resources.SharedResource> Localizer;
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<p>@Localizer[Model]</p>
</div>
通常のアクセスと攻撃用のアクセスを実行してみると、それぞれ以下のような結果になった。
両方問題なく表示された。IStringLocalizer自体はHTMLエスケープを想定したものではないが、ローカライズされた後のテキストがRazorに渡され、Razorの機能によってHTMLエスケープされるためだと考えられる。どちらかというと、Razor的にはHTMLエスケープしないIHtmlLocalizerの方が特殊なんだろう。
ローカライズは主にテキストの多言語対応のためなので、XSSの危険性を増やしてまでタグに対応する必要はないと思うんだが。実際ローカライズされた後のテキストにHTMLのタグを含めたい、って用途はどれくらいあるんだろうか。あんまりないような。。
公式の資料でビューのローカライズはIViewLocalizerとIHtmlLocalizerで説明されているためそっちを使ってしまいがちだが、特にタグを含めたテキストを使わないのであればIStringLocalizerを使った方が安全でいいような気はする。
まとめ
ASP.NETとRazorは大体においてエスケープしてくれて安全性が高いが例外はある。
意識して使っている分には良いが、意識せずに使っていると危険。
外部からのパラメータをHTML出力する場合は気をつけよう。