2011年10月11日火曜日

AllowHtmlの深淵

月刊たけはらブログ。と、なってしまいましたね。

Understanding Request Validation in ASP.NET MVC 3

唐突ですが、↑随分前のこのエントリ、ずっと気になってたんです。ValidateInputAttributeってあるじゃないですか、Ver1の時から。これを指定したControllerやActionは不正なリクエスト文字列が含まれるとHttpRequestValidationExceptionをthrowするやつです。

これ、もともとASPXのPageディレクティブのValidateRequestとしても使われてますね。Pageディレクティブですよ。ココ重要。

MVC3になってからAllowHtmlAttributeって言うモデルプロパティに指定できるやつが追加されたじゃないですか。これ、とても不思議に思ってたんです。でも、まぁいっか、思ったように動くし、と、見て見ぬふりしてたんですけど、どーも、気持ち悪くてですね。

どういうことかというと、PageディレクティブだとHttpRequest.ValidateInput()に直接連携するというのはすんなり受け入れられるけど、それってリクエストコンテキスト単位のチェック(Form,QueryString,Cookieが送信されてきたリクエスト自体)で、個々の要素っていうか値単位のチェックなわけじゃない、っていう仕組みじゃないですか。なのでAllowHtmlってソレまでの仕組みとは全然違うものを実装してるということになりましょう。そうなると、どういう仕組で実装されてるのかキニナル。気になる。

で、先のブログのエントリなんですけど、正直かなり難しそうだなー、と思ってずっと敬遠してました。

さっそく原点、ASP.NET MVC1のControllerActionInvoker見てみます。

[SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "rawUrl",
  Justification = "We only care about the property getter's side effects, not the returned value.")]
private static void ValidateRequest(HttpRequestBase request) {
  // DevDiv 214040: Enable Request Validation by default for all controller requests
  // 
  // Note that we grab the Request's RawUrl to force it to be validated. Calling ValidateInput()
  // doesn't actually validate anything. It just sets flags indicating that on the next usage of
  // certain inputs that they should be validated. We special case RawUrl because the URL has already
  // been consumed by routing and thus might contain dangerous data. By forcing the RawUrl to be
  // re-read we're making sure that it gets validated by ASP.NET.

  request.ValidateInput();
  string rawUrl = request.RawUrl;
}

HttpRequestBaseのValidateInputを呼んで、RawUrlにアクセスするだけのシンプルな実装ですね。コメントから苦労が垣間見れます。

ASP.NET MVC3の同じ部分を見てみます。

internal static void ValidateRequest(ControllerContext controllerContext) {
  if (controllerContext.IsChildAction) {
      return;
  }

  // DevDiv 214040: Enable Request Validation by default for all controller requests
  // 
  // Earlier versions of this method dereferenced Request.RawUrl to force validation of
  // that field. This was necessary for Routing before ASP.NET v4, which read the incoming
  // path from RawUrl. Request validation has been moved earlier in the pipeline by default and
  // routing no longer consumes this property, so we don't have to either.

  ValidationUtility.EnableDynamicValidation(HttpContext.Current);
  controllerContext.HttpContext.Request.ValidateInput();
}

微妙に違いますね。Microsoft.Web.Infrastructure.DynamicValidationHelperのValidationUtility.EnableDynamicValidationですね。誰や!?この旅、ここから長いです。長い割に身が無いです。できることはわかってるんだから。

先のブログエントリを、ちらっと見てるという前提で話を進めますが、MVC3だとHttpRequestのForm,QueryStringへのアクセスでもHttpRequestValidationException発生しますね。<httpRuntime requestValidationMode="2.0"/>はweb.configに入れてません。入れてないとASP.NET4ではデフォルトで全てのコレクションに対する検証がOnになるからです。

ASP.NET 4 Breaking Changes - ASP.NET Request Validation

ASP.NET 2/3.0/3.5と同じ挙動にしたいときだけ。<httpRuntime requestValidationMode="2.0"/>を指定しましょう。必要ないと思いますけど。んで、なんでここに手を入れてるのかというと、検証処理自体細かく制御できるように拡張ポイントを追加したからですね。requestValidationModeともう一つrequestValidationTypeというのも指定できるようになってますが、こっちで検証クラス(検証とずっと言ってるけど通常の入力検証じゃなくてリクエスト検証のことなので誤解しないでね!)を指定してカスタム出来るようになってます。

標準テンプレートのまま1つソリューションを作成します。HomeControllerのIndexで以下のようにQueryStringにアクセスするとします。

public ActionResult Index()
{
  var p1 = Request.QueryString["p1"];

  ViewBag.Message = "Welcome to ASP.NET MVC!";

  return View();
}

なんの変哲もないコードですね。これを実行してブラウザのアドレス欄に”?p1=<a>a</a>”なんていうのを足してアクセスしなおすと、例のエラーでます。そりゃそうです。The XSSです。

rv1

rv2 rv3

今度はそこに↓こんなずるいクラスを追加してrequestValidationTypeに指定。

using System.Web;
using System.Web.Util;

namespace ReqValidate.MVC3
{
  public class PassRequestValidator : RequestValidator
  {
    protected override bool IsValidRequestString(
        HttpContext context,
        string value,
        RequestValidationSource requestValidationSource,
        string collectionKey,
        out int validationFailureIndex)
    {
      validationFailureIndex = -1;
      return true;
    }
  }
}

ひどい。

<httpRuntime
  requestValidationType="ReqValidate.MVC3.PassRequestValidator"/>

このまま先のURLにもう一度アクセス。

rv4

同じようにダメそうなQueryStringを指定してるのに、今度はエラーになりませんでした。より厳しい条件を指定したいときなんかはカスタムすればいいですね。同じ仕組でRawUrlのチェックで許可したい文字を増やす事や、拒否したい文字を減らすこともできるでしょう。残念ながら標準でできるようになってるので、そんなことする意味は無いですけどね。

ASP.NET 4 and Visual Studio 2010 Web Development Overview - Expanding the Range of Allowable URLs

ただ、この辺突き詰めていけば、AllowHtmlにたどり着くのかというと、そうでもない、っていうね。なんでしょーね。この話、必要なかったですね。

話を戻すと、requestValidationModeが2.0の時と、4.0(デフォルト)の時での挙動の違いとして、チェックが有効になるタイミングの違いがあるようです。2.0だとBegineRequestではまだ有効になってなくて、4.0では有効になってます。

Global.asaxでブレークを仕掛けるとわかります。なるほど。Request.ValidateInput()でチェックフラグがオンになるので、以降のForm/QueryStringへのアクセスでチェックがかかるという仕組みです。

requestValidationMode=”2.0”の時。

rv5

Microsoft.Web.Infrastructure.DynamicValidationHelper.ValidationUtility.IsValidationEnabled(HttpContext.Current)で確認しましょう。

falseです。

なので、このタイミングで上記スクリーンショットのQueryStringを参照すると、XSSな値が入っていても、このタイミングではエラーとなりません。

でも、Request.ValidateInput()でマークフラグをセットすると、同じものが今度は例外となります。

rv6

続いて、同じコードでValidateInput()を呼ばずに、requestValidationMode=”4.0”の時。

rv7

IsValidationEnabledもtrueで、例外も起きます。

面白いのは、同じコードを続けて実行すると二回目以降は例外が発生しないところです。これは興味深い挙動ですね。

rv8

不思議な仕様ですねー。だって、この状態でも、Request.Unvlidated().QueryStringでオリジナルを取得することはできるんだから。過去の遺産だったりするのかなー。

rv9

やっと、本題。AllowHtmlを成立させるために必要なのはモデルのプロパティ名毎に検証を実施しないとダメですが、ここまでの流れで分かる通り、マッピング対象のコレクションにアクセスした段階で検証が発動する仕組みなので、このまますんなり行くとは思えません。がしかし、Reques.QueryStringやRequest.Formのアクセスは要素に関係なくエラーになるにもかかわらず、Actionへの引数となるマッピングの場合はマッピングが発生しない要素のにたいする検証は実施されてません。

rv10 rv11

どうやって実現してるんでしょうね。Form/QueryStringへのアクセスはすべて引っ掛けられてるなら途中介入するなんてできそうな気がしないです。

エントリを読み進めるとどうやら、コレクションが内部で保持しているArrayListとHashtableを置き換えてるようです。何と置き換えてるかはエントリの通り、LazyValidationArrayListとLazyValidationHashtableです。Microsoft.Web.Infrastracture.DynamicValidationHelper配下。だからといって、コレでも要素アクセスに限り検証し、プロパティマッピングでは検証しないというルールに繋がらないです。ふむむー。

改めてMVC3のソースを確認すると、そこにはIUnvalidatedRequestValuesと見慣れないインターフェースを使ったValueProviderたちがいました。あれれ?これの実装クラスがUnvalidatedRequestValuesクラスのインスタンスを内包したクラス。UnvalidatedRequestValuesといえば、Request.Unvalidated()で取得できる検証スキップコレクション。

さらに各種ValueProviderはNameValueCollectionValueProviderを派生したもので、IUnvalidatedValueProviderを実装してます。

public interface IUnvalidatedValueProvider : IValueProvider {
  ValueProviderResult GetValue(string key, bool skipValidation);
}

ははーん。見えてきましたね。MVC3からのValueProviderたちはコレを実装した形になっているので、マッピング対象の値をForm/QueryString等のコレクションから取得する際に、こっちのGetValueを呼び出すことで判断してるんですね。この中を少し見てみるとValueProviderResultPlaceholderを各ValueProviderからの戻り値とする際に、検証コレクションと未検証コレクションを切り替えて返す。

検証されるべきコレクションはインフラストラクチャの値をそのまま利用し、検証をスキップしたい時のコレクションはUnvalidatedRequestValuesを利用する。それをModelMetadataのRequestValidationEnabledから判定(AllowHtmlはここをセットするための属性クラス)。すっきりした!

途中出てきたLazyValidationArrayList/LazyValidationHashtableがインフラストラクチャが実施する検証を遅延してくれてるんだろーか。その辺はリフレクションとかしまくっててわかりにくし。FormやQueryStringにアクセスするとエラーが起きるのはそのままだから、なにが遅延かわかりにくいです。どーしてなーん?

こんな感じの実装になってるからAllowHtmlが成立するというのがわかったので良しとします。こうすることでWebPagesとも検証コードが同一のものにできるわけですね(WebPageHttpHandler.ProcessRequestInternalでEnableDynamicValidation)。この辺はSystem.Web.UI.Page派生じゃないものに対しても正しく検証を行うために必要なところですよね。Pageディレクティブないし。

ちょっとスッキリ。

dotnetConf2015 Japan

https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。