SymfonyフォームでCSRF対策

Symfony(2.3以降)フォーム・コンポーネントでCSRF対策

  • Symfony2 のフォームコンポーネントの使い方をサンプル付きで説明します。
  • 日本Symfonyユーザー会サイトの開発チュートリアルを元にしていますが、サンプルコードが古くて動かない箇所がありましたので、2.3で動くサンプルを提供します。

Symfony2

フォームコンポーネント

  • エンドユーザからのデータ入力を受け付け、アプリケーションのデータを変更するための機能です。
  • Validatorコンポーネントと連携して入力チェックを行うことができます。

フォームのCSRF対策

  • CSRF(クロスサイトリクエストフォージェリ)とは、ログイン中のユーザが外部の罠サイト経由で、意図しない更新操作を実行させられてしまう脆弱性です。
  • CSRF対策

    1. 遷移前の画面でログインcookieとは別に、hiddenパラメータとしてトークン(ランダムな秘密情報)を付加する。
    2. 更新画面で所定のトークンが引き継がれていることを確認する。
    3. 正しいトークンを持っていない場合、危険な状態とみなし、遷移を中止する。

    Symfony2では

    • フォームコンポーネントを組み込むと自動的にトークンがhiddenパラメータに追加され、入力チェック(バリデーション)時にトークンチェックが行われます。

実装例

  • 1つのエンティティに対し、入力画面が2つに分かれているケースです。 画面ごとにフォームタイプを用意し、バリデーションも画面ごとにグルーピングしています。
  • フォームタイプ

    • フォームタイプにフォームの生成処理を実装する
    • (htdocs/・・・/src/・・・/・・・/Form\Type/AddressDrinkOrderType.php)
    • class ProductDrinkOrderType extends AbstractType
         {
             /*
              * フォーム生成処理
              */
             public function buildForm(FormBuilderInterface $builder, array $options)
             {
                 $builder->add('product_id',
                      'choice', array( 'choices' => array(
                                     '1' => 'BlueBull 128個入ケース',
                                     '2' => 'GreenBull 128個入ケース')));
                     $builder->add('quantity', 'text');
             }
      

    コントローラ

    • 1. 画面初期表示(GET)
      • 1.1. フォームを生成する(以下、コントローラの該当番号参照)
      • 1.2. フォームをテンプレートに埋め込み、画面をレンダリングする
    • 2. ボタンを押されたタイミングで次画面へ遷移する(POST)
      • 2.1. フォームを生成する(必要に応じてバリデーショングループを指定)
      • 2.2. ブラウザから送信されたデータをフォームに設定する(注意事項参照)
      • 2.3. フォームの有効性をチェックする(バリデーション)
      • 2.4. フォームが有効であれば、適当なアクション(DBへのデータ保存など)を実行し、リダイレクトを行う
        • 2.4.1. フォームが無効なら、(エラーメッセージと共に)再度フォームを表示
    • (htdocs/・・・/src/・・・/・・・/Controller/HelloController.php)
      container->get('session')->has('drinkOrder')) {
                 $this->container->get('session')->set('drinkOrder', new DrinkOrder());
             }
             // 1.1. フォームを生成する
             $form = $this->createForm(new ProductDrinkOrderType(),
                     $this->container->get('session')->get('drinkOrder'));
             // 1.2. フォームをテンプレートに埋め込み画面をレンダリングs
             return $this->render('sampExamBundle:Hello:product.html.twig', array('form' => $form->createView()));
         }
         /*
          * 2. ボタンを選択されたタイミングで次画面へ遷移する
          * (POST)
          */
         public function productPostAction()
         {
             // 2.1.フォームを生成する(必要に応じてバリデーショングループを指定)
             $form = $this->createForm(new ProductDrinkOrderType(),
                     $this->container->get('session')->get('drinkOrder'),
                     array('validation_groups' => array('product'))
             );
       
             // 2.2.ブラウザから送信されたデータをフォームに設定する
             $form->handleRequest($this->getRequest());
       
             // 2.3. フォームの有効性をチェックする(バリデーション)
             if ($form->isValid())
             {
                 // 2.4. フォームが有効の場合、次画面へリダイレクトする
                 return $this->redirect($this->generateUrl('address'));
             }
             else
             {
                 // 2.4.1.フォームが無効の場合、エラーメッセージ付きで同じ画面を再表示する
                 return $this->render('SampExamBundle:Hello:product.html.twig', array('form' => $form->createView()));
             }
         }
         /*
          * お届け先入力(GET)
          */
         public function addressAction()
         {
             // フォームの生成
             $form = $this->createForm(new AddressDrinkOrderType(), $this->container->get('session')->get('drinkOrder'));
             return $this->render('SampExamBundle:Hello:address.html.twig', array('form' => $form->createView()));
         }
         /*
          * お届け先入力(POST)
          */
         public function addressPostAction()
         {
             // フォームの使用
             $form = $this->createForm(new AddressDrinkOrderType(),
                     $this->container->get('session')->get('drinkOrder'),
                     array('validation_groups' => array('address'))
             );
             $form->handleRequest($this->getRequest());
             if ($form->isValid())
             {
                 return $this->redirect($this->generateUrl('confirmation'));
             }
             else
             {
                 return $this->render('SampExamBundle:Hello:address.html.twig', array('form' => $form->createView()));
             }
         }
         /*
          * 入力確認(GET)
          */
         public function confirmationAction()
         {
             $form = $this->createFormBuilder($this->container->get('session')->get('drinkOrder'))->getForm();
      
             return $this->render('SampExamBundle:Hello:confirmation.html.twig', array('form' => $form->createView()
                     , 'drinkOrder' => $this->container->get('session')->get('drinkOrder')));
         }
         /*
          * 入力確認(POST)
          */
         public function confirmationPostAction()
         {
             $form = $this->createFormBuilder($this->container->get('session')->get('drinkOrder'))->getForm();
             $form->handleRequest($this->getRequest());
             $this->container->get('session')->remove('drinkOrder');
             return $this->redirect($this->generateUrl('success'));
         }
         /*
          * 注文完了
          */
         public function successAction()
         {
             return $this->render('SampExamBundle:Hello:success.html.twig');
         }
      }

    バリデーション

    • バリデーションは、yml、xml、PHPのどれかで設定します。(XMLは使用頻度が低いため、今回は省略します)
      • yml の場合

      • (htdocs/・・・/src/・・・/・・・/Resources/config/validation.yml)
        Samp\ExamBundle\Entity\DrinkOrder:
           properties:
               productId:
                   - NotBlank: { groups: [product] }
                   - Range: { min: 1, max: 2, minMessage: '1以上の値を入力してください', maxMessage: '2以下の値を入力してください', groups: [product] }
               quantity:
                   - NotBlank: { groups: [product] }
                   - Range: { min: 1, max: 8, minMessage: '1以上の値を入力してください', maxMessage: '8以下の値を入力してください', groups: [product] }
               name:
                   - NotBlank: { groups: [address] }
                   - Length: { min: 1, max: 10, minMessage: '1文字以上入力してください', maxMessage: '10文字以下で入力してください', groups: [address] }
               address:
                   - NotBlank: { groups: [address] }
                   - Length: { min: 1, max: 50, minMessage: '1文字以上入力してください', maxMessage: '50文字以下で入力してください', groups: [address] }
               phone:
                   - NotBlank: { groups: [address] }
                   - Length: { min: 1, max: 16, minMessage: '1文字以上入力してください', maxMessage: '16文字以下で入力してください', groups: [address] }
      • PHPの場合

      • (htdocs/・・・/src/・・・/・・・/Entity/DrinkOrder.php に追記)
        /*
         * 以下、validation.yml を使用する場合は不要。
         */
         public static function loadValidatorMetadata(ClassMetadata $metadata)
         {
            $metadata->addPropertyConstraint('productId', new NotBlank(
                    array('groups' => array('product')
            )));
            $metadata->addPropertyConstraint('productId', new Assert\Range(array(
                    'min'        => 1,
                    'max'        => 2,
                    'minMessage' => '1以上の値を入力してください',
                    'maxMessage' => '2以下の値を入力してください',
                    'groups' => array('product')
            )));
            $metadata->addPropertyConstraint('quantity', new NotBlank(
                    array('groups' => array('product')
            )));
            $metadata->addPropertyConstraint('quantity', new Assert\Range(array(
                    'min'        => 1,
                    'max'        => 8,
                    'minMessage' => '1以上の値を入力してください',
                    'maxMessage' => '8以下の値を入力してください',
                    'groups' => array('product')
            )));
            $metadata->addPropertyConstraint('name', new NotBlank(
                    array('groups' => array('address')
            )));
            $metadata->addPropertyConstraint('name', new Assert\Length(array(
                    'min' => 1,
                    'max' => 10,
                    'minMessage' => '1文字以上入力してください',
                    'maxMessage' => '10文字以下で入力してください',
                    'groups' => array('address')
            )));
            $metadata->addPropertyConstraint('address', new NotBlank(
                    array('groups' => array('address')
            )));
            $metadata->addPropertyConstraint('address', new Assert\Length(array(
                        'min' => 1,
                        'max' => 50,
                        'minMessage' => '1文字以上入力してください',
                        'maxMessage' => '50文字以下で入力してください',
                        'groups' => array('address')
            )));
            $metadata->addPropertyConstraint('phone', new NotBlank(
                    array('groups' => array('address')
            )));
            $metadata->addPropertyConstraint('phone', new Assert\Length(array(
                        'min' => 1,
                        'max' => 16,
                        'minMessage' => '1文字以上入力してください',
                        'maxMessage' => '16文字以下で入力してください',
                        'groups' => array('address')
            )));
         }

注意事項

  • Symfonyのバージョンによって、ブラウザから送信されたデータをフォームに設定する方法が異なります。
  • バージョン設定方法
    Symfony2.0 Form->bindRequest()
    Symfony2.1 Form::bindRequest() が非推奨化、Form::bind()へ。今まで bindRequest() が行っていた役割のほか、特定のフィールドのバインドも可能になった。
    Symfony2.3 Form::bindRequest() は廃止。Form::handleRequest() が追加。Form::bind() が非推奨化、同様の役割を Form::submit() が行うようになるが、将来的に Request のバインドができなくなる。以下のように使い分けるのがよさそうです・Requestごとバインド ⇒ Form::handleRequest()・特定フィールドのバインド ⇒ Form::submit()
  • フォームを組み込むと、トークンが自動的に発行されるようになりますが、CSRF対策のためには Form::isValid() を実行し、トークンを照合する必要があります。
  • validation.yml の記述に誤りがあっても、ほとんどの場合、エラーにならず、スルーされます。
  • 開発チュートリアルのサンプルは古いので、設定方法はバリデーションのリファレンスを確認してください。
  • バリデーションのリファレンスには、グルーピングについて記載されていません。
  • グルーピングするには、「{}」を使用し、かつ「{}」内で改行せず、カンマ区切りにする必要があります。
  • 最悪、yml でなく、PHP で設定することができます。
  • PHP での設定する場合は、EntityにloadValidatorMetadata() メソッドを追加してください。

参考文献

トラックバック


この記事へのトラックバック一覧です: SymfonyフォームでCSRF対策:

コメント

コメントを書く



(ウェブ上には掲載しません)