Razorテンプレートで柔軟で安全なヘルパーを作る

このエントリーはOne ASP.NET Advent Calendar 2013の16日目です。

現在ASP.NETをお使いのほぼ100%の方がASP.NET Web PagesかASP.NET MVCでRazorテンプレートを利用していると思います。そのRazorテンプレートにはヘルパーと呼ばれているHTML片を切り出してメソッドのようにして呼び出し、再利用できるような仕組みがあります。

まあ最近のテンプレートエンジンならヘルパー機能は大体ついているような気がしますが、例えばRazorでa要素でリンクを生成するヘルパーを作るとするとこんな感じになります。

@* ヘルパーの定義 *@
@helper Link(string content, string url) {
	<a href="@url">@content</a>
}

@* ヘルパーの呼び出し *@
@Link("リンク文字列", "http://www.example.com/")

RazorテンプレートのヘルパーはRazorテンプレートのままかけるので、他のテンプレートエンジンのいわゆるヘルパーよりパーシャルテンプレートにも近いかもしれません。
この時点でヘルパーの内容に関してはある程度柔軟に書けるようになっています。文字列とかを組み立てて返すといったことがないのは楽ですね。

これをさらにもうちょっと安全に柔軟にしようというのが今回のお話です。

外からHTMLを渡す

先ほどの例で出したa要素でリンクを出力するだけのLinkヘルパーですが、普通に使ってると物足りなくなります。すぐにぶち当たるのが「画像をリンクにしたい」というHTMLを渡して出力したいパターンです。

試しに以下のように内容として渡してみます。

@Link("<img ... />", "/")

そうすると以下のようなHTMLが出力されてしまい、

<a href="/">&lt;img ... /&gt;</a>

結果としてブラウザには<img />という文字列が表示されます。

それもそのはずstringを@で出力するときには自動的にHTMLエスケープがかかるからです。ということはHTMLエスケープを外せばよさそうですね。HTMLをそのまま出力するにはHtml.Rawヘルパーを使います。

@helper Link(string content, string url) {
	<a href="@url">@Html.Raw(content)</a>
}

これでどうでしょう?これは目的は達成できますが悪いヘルパーです。というのもcontentにわたってくるものがユーザー由来のものや含むものだった場合XSSが発生する可能性があるからです。だから自動エスケープしてるというのに…。
つまり使うときは次のように気を付けてエスケープしつつ使わなければなりません。

@Link("<img />" + Html.Encode(value) + "<img />", "/")

これは前後にHTMLっぽいものがあるのでまだ気がつきやすいですが

@Link(value + "さんのマイページ", "/")

のようなものはほぼ確実に見落とします。もし <xmp> さんがいた場合、大変残念なことになります。

そこで反対に入り口を増やしてあげる方法をとります。入り口を増やすというのは「HTMLエスケープされている文字列を受け取るもの」と「HTMLエスケープされていない文字列を受け取るもの」の二つを用意するということです。
実はASP.NETには「HTMLエスケープされている文字列」を表すインターフェースがすでに用意されていてIHtmlStringインターフェースというものがありますのでそれを受け取ります。さっきのHtml.Rawは要するにIHtmlStringを持つ何かでくるんでくれるヘルパーだったというわけです。

というわけでもう一つヘルパーを作ります。ヘルパーも引数のオーバーロードができるのでそれで対応できます。

@helper Link(IHtmlString content, string url) {
	<a href="@url">@content</a>
}
@helper Link(string content, string url) {
	<a href="@url">@content</a>
}

こんな感じにして…

@{ var name = "<xmp>"; }
@Link(name +"さんのマイページ", "/")
@Link(Html.Raw("<img />" + Html.Encode(name) +"さん"), "/")

これで…

<a href="/">&lt;xmp&gt;さんのマイページ</a>
<a href="/">&lt;img /&gt;&lt;xmp&gt;さん</a>

ほうほう、これでよさそうですね。さらにこのヘルパーはIHtmlString版を呼び出すことでまとめることができます。

@helper Link(IHtmlString content, string url) {
	<a href="@url">@content</a>
}
@helper Link(string content, string url) {
	@Link(new HtmlString(Html.Encode(content)), url)
}

これでHTMLを渡していること明示して安心できました。めでたしめでたし。

Html.Raw Considered harmful: より簡単に。より安全に。

IHtmlStringを受け取ることにしたことで明示できるようになりましたが普通に危険です。というのもIHtmlStringを受け取るところではHtml.Rawを通すので結局エスケープ漏れを起こす可能性が十分にあります。

@{ var name = "\">"; }
@Link(Html.Raw("<img title=\" + name + "\"/>" + Html.Encode(name) +"さん"), "/")

というわけでテンプレートを書く上でHtml.Rawは悪です…なので呼び出し側からHtml.Rawをなくします。
どのようにしてHtml.Rawをなくすのかというと、引数にRazorのテンプレート片を渡せるようにできるのでそれをやります。Func<dynamic, HelperResult>という型をとるようにするだけです。

@helper Link(Func<dynamic, HelperResult> content, string url) {
	<a href="@url">@content(null)</a>
}
@helper Link(string content, string url) {
	@Link(new HtmlString(Html.Encode(content)), url)
}
@helper Link(IHtmlString content, string url) {
	<a href="@url">@content</a>
}

Razorテンプレート片がFunc<dynamic, HelperResult>に変換されて渡されるので呼び出すとRazorテンプレートのレンダリング結果が返ってきます。
で、実際の呼び出しはこんな感じでRazorテンプレート片、つまりHTML的なものを渡せます。

@{ var name = "\"><xmp>"; }
@Link(@<img title="@name" />, "/")

そして次のような結果となります。

<a href="/"><img title="&quot;&gt;&lt;xmp&gt;" /></a>

属性値がちゃんとエスケープされています。すばらしい。

文字列も含めて渡したいなーとかいう場合には次のようにtextタグを使って書けます。

@{ var name = "\"><xmp>"; }
@Link(@<text>【<img title="@name" />@name】</text>, "/")

これは以下のようになります。

<a href="/"><img title="&quot;&gt;&lt;xmp&gt;" />&quot;&gt;&lt;xmp&gt;</a>

すばらです。このような形でHtml.Rawを使わなくても簡単にしかも安全に出力できるようになりました。

ちなみにHelperResultクラスはIHtmlStringを実装しているのでそのままIHtmlStringオーバーロードに渡せます(というのを記事書いてて気付きました)。

@helper Link(Func<dynamic, HelperResult> content, string url) {
	@Link(content(null), url)
}
@helper Link(string content, string url) {
	@Link(new HtmlString(Html.Encode(content)), url)
}
@helper Link(IHtmlString content, string url) {
	<a href="@url">@content</a>
}

例えば、Html.Rawを避ける

Html.Rawはヘルパーの中で組み立てたときとかサーバー側でHTML組み立てていて、どうしてもというときに使うのはいいのですが、それでもやはり局所的にしておかないと結局同じ問題が発生するので極力避けたほうが良いでしょう。

個人的にはHtml.Rawなどという生ぬるい名前が良くないのではないかと思っているのでHtml.RawにObsoleteを付けてHtml.Unsafeという名前のヘルパーを作ったりしています(本当はHtml.SuperDangerousUnsafeUnescapedHtmlStringみたいな名前にしようかと思ってました)。

なのでこのHtml.Rawを避けるためにFunc<dynamic, HelperResult>を使うというのは覚えておくと色々と役立ちます。

Func<dynamic, HelperResult>の制限

Func<dynamic, HelperResult>のおかげで安全に書けるようになって素晴らしい限りですが一つ気を付けるべき点があります。

Func<dynamic, HelperResult>はFunc<dynamic, HelperResult>を含められない…というか単純にRazor記法の制約としてタグブロックをネストすることはできません。

つまりどういうことかというと以下のようなコードはエラーになります。

@helper Hauhau(Func<dynamic, HelperResult> content) { @content(null) }
@helper Hauhau(string content) { @content }
@* これはOK *@
@Hauhau(@<text>@Hauhau("hoge")</text>)
@* これはNG *@
@Hauhau(@<text>@Hauhau(@<img />)</text>)

なのでブロックを吐き出すヘルパーを作ったりするとハマるのでご注意ください。

Func<dynamic, HelperResult>の引数

ところでいまさらですがFunc<dynamic, HelperResult>、dynamicってなによ。しかもnullで呼び出してたしって気になりますよね。

実はこの引数に渡した値はRazorテンプレート片(実体はFunc)に引数 item という名前で渡されます。

@helper Homuhomu(Func<dynamic, HelperResult> content)
{
    @content(new { Value = 1 })
}
@Homuhomu(@<text>@item.Value</text>)

さらに、dynamicである必要性もなかったりします。dynamicである必要はないのですが必ず引数が一つ必要になりますので慣習的にdynamic(またはobject)です。まあThreadのstateとかと同じですね。というわけで、なんらかの型を指定できます。

@helper Homuhomu(Func<DateTime, HelperResult> content)
{
    @content(DateTime.Now)
}
@Homuhomu(@<text>@item.Month</text>)

ちゃんとIntelliSenseもききます。この使い方はいざというときに役に立つことがあるのでオススメです。

f:id:mayuki:20131216000047p:plain

まとめ

Html.Rawダメです。Func<dynamic, HelperResult>はオッケーです。

OWINネタを書こうと思っていたのですが普通に忘れてヘルパーについて一生懸命書いてました…。なにか他にも書くものがあったような…。

あとはてなグループ日記はいい加減ひどすぎだとおもいます。

これが噂の…

はてブロとやらですか。

自由度が高まってそうでよさそうですね。