SymfonyフォームでCSRF対策
Symfony(2.3以降)フォーム・コンポーネントでCSRF対策
- Symfony2 のフォームコンポーネントの使い方をサンプル付きで説明します。
- 日本Symfonyユーザー会サイトの開発チュートリアルを元にしていますが、サンプルコードが古くて動かない箇所がありましたので、2.3で動くサンプルを提供します。
Symfony2
- 大抵のものは揃っていると言われるPHPフレームワークですが、ドキュメントが少ないのが玉にキズ。
- Symfony2ドキュメントポータル
フォームコンポーネント
- エンドユーザからのデータ入力を受け付け、アプリケーションのデータを変更するための機能です。
- Validatorコンポーネントと連携して入力チェックを行うことができます。
フォームのCSRF対策
- CSRF(クロスサイトリクエストフォージェリ)とは、ログイン中のユーザが外部の罠サイト経由で、意図しない更新操作を実行させられてしまう脆弱性です。
- 遷移前の画面でログインcookieとは別に、hiddenパラメータとしてトークン(ランダムな秘密情報)を付加する。
- 更新画面で所定のトークンが引き継がれていることを確認する。
- 正しいトークンを持っていない場合、危険な状態とみなし、遷移を中止する。
- フォームコンポーネントを組み込むと自動的にトークンがhiddenパラメータに追加され、入力チェック(バリデーション)時にトークンチェックが行われます。
CSRF対策
Symfony2では
実装例
- 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は使用頻度が低いため、今回は省略します)
- (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] }
- (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') ))); }
フォームタイプ
コントローラ
バリデーション
yml の場合
PHPの場合
注意事項
- Symfonyのバージョンによって、ブラウザから送信されたデータをフォームに設定する方法が異なります。
- フォームを組み込むと、トークンが自動的に発行されるようになりますが、CSRF対策のためには Form::isValid() を実行し、トークンを照合する必要があります。
- validation.yml の記述に誤りがあっても、ほとんどの場合、エラーにならず、スルーされます。
- 開発チュートリアルのサンプルは古いので、設定方法はバリデーションのリファレンスを確認してください。
- バリデーションのリファレンスには、グルーピングについて記載されていません。
- グルーピングするには、「{}」を使用し、かつ「{}」内で改行せず、カンマ区切りにする必要があります。
- 最悪、yml でなく、PHP で設定することができます。
- PHP での設定する場合は、EntityにloadValidatorMetadata() メソッドを追加してください。
バージョン | 設定方法 |
---|---|
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() |
コメント