symfony1.2のCSRF対策について

こんにちは、小川です。
symfony1.2ではsfFormクラスを用いてフォームのレンダリングや入力項目のバリデーションを行います。このsfFormクラスにはCSRF対策も実装されているのはご存じでしょうか。
今回はこのCSRF対策が具体的にどのように行われているかをお話ししたいと思います。

先にどのような手法で対策を行うかですが、フォームごとに異なるトークンをHTML上に埋め込み、その値をバリデーション時にチェックするという方法で対策を行っています。
具体的にどのようにトークンが生成され、どのようにチェックを行っているかは後ほど詳しく説明します。

CSRF対策を有効にするためにはどうすれば良いでしょうか。Jobeetなどでsymfony1.2について学んだ方はご存じかと思います。
CSRF対策は各アプリケーションごとに設定可能で、アプリケーション作成時に以下のようにすることで有効になります。


$ symfony generate:app --escaping-strategy=on --csrf-secret=myUniqueSecret frontend

通常symfonyコマンドのgenerate:appタスクを用いてアプリケーションのスケルトンを生成しますが、タスク実行時にcsrf-secretというオプションを指定します。最初の説明でトークンを埋め込むと説明しましたが、このトークンを生成するときのソルト値として使用する値を指定します。上記の例ではmyUniqueSecretがソルト値になります。

実際にcsrf-secretというオプションを指定してアプリケーションを生成した場合、アプリケーションのconfigディレクトリ内にあるsettings.ymlという設定ファイルの内容が変化します。


all:
  .settings:
    csrf_secret:            myUniqueSecret

allのcsrf_secretという項目に、先ほどのcsrf-secretオプションの値が入っていると思います。もしgenerate:appタスク実行時にcsrf-secretを指定しなかった場合はfalseとなり、CSRF対策が有効になりません。もし有効に設定しなかった場合はsettings.ymlを直接変更してキャッシュをクリアすれば有効になります。

有効にするだけであればとてもシンプルですが、フォームクラスを利用するときにはどのような注意が必要なのでしょうか。通常フォームクラスには「ウィジェット」と「バリデータ」を指定することでフォーム要素の作成とバリデーションを行っていきます。CSRF対策のトークンはどのようにして扱えば良いのでしょうか。実際にコードを書いて説明していきます。


// lib/form/doctrine/UserForm.class.php
<?php
class UserForm extends BaseUserForm
{
  public function configure()
  {
    $this->setWidgets(array(
      'email' => new sfWidgetFormInput(),
    ));
    $this->setValudators(array(
      'email' => new sfValidatorEmail(array(), array(
        'required' => 'メールアドレスを入力してください',
        'invalid'  => 'メールアドレスを正しく入力してください',
      )),
    ));
  }
}


// apps/frontend/modules/user/actions/actions.class.php
<?php 
class userActions extends sfActions
{
  public function executeNew(sfWebRequest $request)
  {
    $this->form = new UserForm();
  }
  public function executeCreate(sfWebRequest $request)
  {
    $this->forward404Unless($request->isMethod('post'));
    $this->form = new UserForm();
    $this->form->bind($request->getParameter($this->form->getName()));
    if ($this->form->isValid()) {
      $this->form->save();
      $this->redirect('user/show?id='.$this->form->getObject()->getId());
    }
    $this->setTemplate('new');
  }
}


// apps/frontend/modules/user/templates/newSuccess.php
<?php echo $form->renderFormTag() ?>
  <?php echo $form->renderGlobalErrors() ?>
  <?php echo $form->renderHiddenFields() ?>
  <div>
    メールアドレス:<?php echo $form['email'] ?>
    <?php echo $form['email']->renderError() ?>
  </div>
  <p>
    <input type="submit" value="Save!" />
  </p>
</form>

簡単なフォームクラス、アクション、テンプレートを書いてみました。実はこれだけでも既にCSRF対策は行われています。
はじめにHTML上にトークンを埋め込むと言いましたが、テンプレート上のどこにあるのでしょうか?トークンのレンダリングは$form->renderHiddenFields()で行われています。settings.yml上でcsrf-secretが指定されている場合、各フォームにデフォルトで_csrf_tokenという名前のhiddenフィールドが自動的に作成されます。
renderHiddenFieldsメソッドは全てのhiddenフィールドをレンダリングするためのメソッドなので、このメソッドをテンプレート内に記述していることでトークンがHTML上に埋め込まれるようになります。

基本的にこのrenderHiddenFieldsメソッドはhiddenフィールドを設定していない場合でも必ずテンプレートに記述するようにしましょう。そうしないとCSRF対策のトークンが送られず、CSRFだと判断してしまいます。

リクエストがCSRFによるものかはバリデーション時に判定しています。$form->isValid()というのがそれです。UserFormの定義ではemailに対してsfValidatorEmailバリデータを指定しています。基本的にバリデータはウィジェットとセットで定義を行いますが、先ほどの_csrf_tokenにもバリデータが定義されています。このトークンにはsfValidatorCSRFTokenという専用のクラスが用意され、送られてきたトークンが正しいものかのチェックを行っています。

ですのでテンプレート側でrenderHiddenFieldsメソッドを実行してさえいれば、バリデーション時に必ずチェックしてくれるので特に意識することなくCSRF対策が行われるような仕組みになっています。

ではこのトークンはどのようなロジックで生成されているのでしょうか。このトークンはワンタイムトークンと呼ばれているものでは厳密にはありません。sfFormクラスにそのロジックがありますので実際にみてみましょう。sfFormのコンストラクタでaddCSRFProtectionというメソッドが呼ばれており、そこに記述されています。


<?php
public function addCSRFProtection($secret)
{
  if (false === $secret || (is_null($secret)  & & !self::$CSRFProtection))
  {
    return;
  }
  if (is_null($secret))
  {
    if (is_null(self::$CSRFSecret))
    {
      self::$CSRFSecret = md5(__FILE__.php_uname());
    }
    $secret = self::$CSRFSecret;
  }
  $token = $this->getCSRFToken($secret);
  $this->validatorSchema[self::$CSRFFieldName] = new sfValidatorCSRFToken(array('token' => $token));
  $this->widgetSchema[self::$CSRFFieldName] = new sfWidgetFormInputHidden();
  $this->setDefault(self::$CSRFFieldName, $token);
}
public function getCSRFToken($secret = null)
{
  if (is_null($secret))
  {
    $secret = self::$CSRFSecret;
  }
  return md5($secret.session_id().get_class($this));
}

addCSRFProtectionの引数は通常nullが来ます。これはsfFormのコンストラクタの第3引数に渡した値が使用され、通常指定することはありませんのでコンストラクタのデフォルト値であるnullが使用されます。
引数はnullですが、self::$CSRFProtectionにsettings.ymlで記述しているcsrf_secretの値が既に入っている状態なので、このソルト値を元にgetCSRFTokenメソッドから実際にレンダリングに使用されるトークンの値の取得を行っています。

getCSRFTokenメソッドのロジックをみるとわかるとおり、ソルト値・セッションID・クラス名をくっつけた値のMD5ハッシュ値が利用されています。ソルト値とクラス名はアプリケーションのソースコードに手を入れない限り変更されないので、セッションIDが変わらない限りはこのトークンの値は変わらない仕様になっています。これは、例えばタイムスタンプなどを利用してトークンを生成する方法ではそのトークンを別のところに格納しておく必要があり、フォームクラスのみで実装することが難しいからだと思われます。
またget_class関数でクラス名を取得していますが、これは__CLASS__定数とは違い実行時に評価されますので、フォームクラスが違えばそのたびにトークンの値も違う値が使用されるようになっています。

上記までで大体の実装方法はおわかりいただけたでしょうか。覚えておく必要があるのは、アプリケーション作成時にcsrf-secretの指定を行うことと、必ずrenderHiddenFieldsメソッドをテンプレート側で呼び出すことの2つ程度で、後は特に意識せずに行うことが可能です。トークンのフィールドを自動的に作られるようになっていますが、embedForm時などには削除も行われたりしますので自動でつくことに対する副作用は特にないのではないでしょうか。

symfony1.0のころにもCSRF対策用のプラグインは存在していました。フィルターを用いてHTML内のmethod=postなform要素を探してtoken用のhiddenを埋め込み、POSTでリクエストが来たときに同じフィルターでチェックをするということをやるものでした。ただしこれは、link_toのオプションでmethod=>postを指定した場合にトークンを仕込むことができなかったり、CSRFアタックを検知した場合に例外になったりと画面ごとに制御ができず、どうしたものかと悩むことも多々ありました。

ですが今回、フォームがオブジェクトになったことによりフォーム単位で指定が変えられ、バリデーション時に一緒にCSRFアタックの検知もしてくれるので非常に使い勝手が良くなりました。
プラグインの頃はできなかったlink_to($name, $url, array('method' => 'post'))のようなときにも現在は対応しています。その場合にonclick属性に指定されるJavaScriptを生成しているのがUrlHelperの_method_javascript_functionになります。


<?php
function _method_javascript_function($method)
{
  $function = "var f = document.createElement('form'); f.style.display = 'none'; this.parentNode.appendChild(f); f.method = 'post'; f.action = this.href;";
  if ('post' != strtolower($method))
  {
    $function .= "var m = document.createElement('input'); m.setAttribute('type', 'hidden'); ";
    $function .= sprintf("m.setAttribute('name', 'sf_method'); m.setAttribute('value', '%s'); f.appendChild(m);", strtolower($method));
  }
  // CSRF protection
  $form = new sfForm();
  if ($form->isCSRFProtected())
  {
    $function .= "var m = document.createElement('input'); m.setAttribute('type', 'hidden'); ";
    $function .= sprintf("m.setAttribute('name', '%s'); m.setAttribute('value', '%s'); f.appendChild(m);", $form->getCSRFFieldName(), $form->getCSRFToken());
  }
  $function .= "f.submit();";
  return $function;
}

上記のように空のsfFormを作成してCSRF対策が有効になっているか(settings.ymlでcsrf_secretが設定されているか)を判定して自動的にトークンを含めるということを行ってくれています。
これをアクション側で判断するのも簡単です。sfWebRequestにcheckCSRFProtectionメソッドが実装されていますので基本的にはこちらを使用します。


<?php
class sfWebRequest extends sfRequest
{
  public function checkCSRFProtection()
  {
    $form = new sfForm();
    $form->bind($form->isCSRFProtected() ? array($form->getCSRFFieldName() => $this->getParameter($form->getCSRFFieldName())) : array());
    if (!$form->isValid())
    {
      throw $form->getErrorSchema();
    }
  }
}

エラーが起こった場合には上記の通り、バリデーションエラーをまとめたsfValidatorErrorSchemaクラスをスローしますので、アクション側でcheckCSRFProtectionメソッドを呼び出して適切にハンドリングしてあげることでCSRF対策が可能です。

symfony1.2では1.0に比べて、あまり目立たないかもしれませんがこういったセキュリティ面でも進化しています。XSS対策用のsfOutputEscaperオブジェクトまわりも1.0にはない機能がいくつか追加されていたり、内部を見ていくと本当に様々なところで進化を実感できます。
1.3や1.4で大きな変更がないことを考えると、symfony1.xとしての形はだいぶ固まってきた感じがしますね。symfonyのコードもだいぶみてきましたが、より高度な情報を配信できるようにもっと隅々まで探っていくつもりなので今後ともよろしくお願いいたします。