CodeIgniterの学習 39 - 俺俺改造版csrfライブラリ(ワンタイムトークンCSRF対策)を改良して、一時データをセッションに保持し簡単かつ極力安全に取得消去できるようにする

今日は以前のエントリ
CodeIgniterの学習 23 - ワンタイムチケット(ワンタイムトークン)の機能を作って、CSRF対策をちょっとした気分になってみる
http://d.hatena.ne.jp/dix3/20081017/1224196292

CSRFライブラリを表題のように改良したので、使う人はいないかもしれんが貼っておく。

概要と使い方は以前とほぼ同じなので省略する。


変更点

  1. $this->db_session → $this->session にした。(CodeIgniter Version1.7.0のSession.phpに移行したため)
  2. 問い合わせ処理の軽量化(クエリ発行回数を抑えた)
  3. get_sessh() get_sessh_all() を追加し、tokenバリデーション付きで簡単にsession内にhidden扱いのデータ保持と取り出しを出来るようにした


1)の修正は、これまでのエントリの経緯ゆえ。($this->db_sessionに置換しても動く)

2)の修正は、クエリ発行回数を抑えるため、同時にSetCookieの走る回数を抑えるため。

3)の追加は、セッションデータ内に、現在編集中のid等を一時的に保存したい時用。(<input type="hidden" … > に一時データを持たせないようにするため)


3の機能追加の理由

要はセッションデータ上に、現在編集中のidとかを持たせて、DBへの更新はそのidを使おうという話。

大きな理由:

  1. urlとかhiddenとかに安直に保持すると改変しまくりが出来てしまう危険性がある。
  2. セッションデータ上に無秩序に一時データを保存していくのは、ゴミがたまる(除去漏れの)可能性があり、同時にそのゴミデータを間違えて(意図しないタイミングで)再利用してしまう危険性がある。
  3. CSRFのチェック付きでセッションデータ上に一時データを保存すると、CSRFバリデーションエラー時には値が取り出せないので、一時保存データとして好都合。
  4. あんまり一時データの保存、削除で気をもみたくない。

という理由から。

(CodeIgniterには、flashdata()ってのがあるので別にflashdataを使えば事足りるかもしれんが、
俺的にはcsrf判定も同時に噛ましたいのでこうなった。)


3の機能追加のサンプル

サンプルコード全部を載せると疲れるので、ポイントのみ。
基本は以前のエントリhttp://d.hatena.ne.jp/dix3/20081017/1224196292を参照してください。
(まだ改良するかも知れないけど、これでもそこそこ使えると思う。)


1:ビュー側(一時保存データの格納):

<?=form_csrf('hogehoge_edit',array('id'=>$this->validation->id,'mode'=>$this->validation->mode));?>

みたいに、第二引数にセッションデータ上に一時保存したいデータを配列(キー&値)で渡す。

CSRFチェック用のhidden項目以外には、hidden項目は作られない。

渡されたデータは、下の写真の様にセッションデータ上に保存される。(都合上一部マスクしている。赤でマスクしているところが 、ここでいうhogehogeだと思ってください。)

写真の様に、

Array
(
    [check_token_key] => tkn_ys3n26
    [check_token_value] => f29aced541a0c92dd39622fda8a996e8795fb18f
    [data] => Array
        (
            [id] => 12
            [mode] => edit
        )

)

みたいな形で、form_csrf()の第一引数をベースのキーとしたデータが保存される。
check_token_keyとcheck_token_valueは、後で値を取り出す時の整合性チェック用として自動で作られる。
data 以下に第二引数のデータが入る。


2:コントローラ側(格納した値の取り出し):

取り出したいタイミングで、(フォームデータのsubmit後の処理等で)

$id = $this -> csrf -> get_sessh( "hogehoge_edit", 'id' );
$mode = $this -> csrf -> get_sessh( "hogehoge_edit", 'mode' );

とかで一個ずつ取り出す。
又は、

$data =get_sessh_all("hogehoge_edit"); 

で全部取り出す。( この例だと、$data = array('id'=>12,'mode'=>'edit') が取り出せる。)


コード的には、例えばこんな感じになる

<?php
  //上略

  //データのポスト後の処理
  function submit()
  {
    if ( !$this -> input -> post( "submit" ) ) {
      // URL直呼びはeditへリダイレクト
      redirect( "/nantoka/edit/" );
    }
    //csrfのチェック、第二引数をfalseにしておいて、
    //チェックしてもすぐにはcsrfのセッションデータを消さない($id,$modeを取り出すから)
    if ( !$this -> csrf -> check( "hogehoge_edit", false ) ) { // csrfエラー
      redirect( "/nantoka/error/" );
    }
    //csrf整合性チェック付きのデータを取り出す。整合性がずれているとNULLが返ってくる
    $id = $this -> csrf -> get_sessh( "hogehoge_edit", 'id' );
    $mode = $this -> csrf -> get_sessh( "hogehoge_edit", 'mode' );

   //バリデーション処理
   //中略

   //idとmodeと、postデータを使って何かの登録更新処理
   //中略
   
   //csrfデータの除去
   $this -> csrf -> clean( "hogehoge_edit" );
  }
  //下略
 ?>


除去しなくてもcsrfは一回限りなのだがゴミは放置せずにすぐ消す。


てな感じ。


ソース

いつもの事ながら無保証。

1:ライブラリ application/libraries/Csrf.php

<?php if ( !defined( 'BASEPATH' ) ) exit( 'No direct script access allowed' );
// ワンタイムチケット(ワンタイムトークン)のライブラリ俺俺改造
// 元ネタと使い方 http://codeigniter.com/forums/viewthread/71312/
// inspired http://codeigniter.com/forums/viewthread/71312/
// changes 変更点
// 1.session専用に改造($_SESSION は使わない)
// 2.ヘルパをやめてライブラリ(いんちきクラス)化した。
// 3.トークンのバリデーションは自動で走るのではなく、明示的に呼び出すようにした。
// 4.submit先が、元の画面と異なる場合でも動くようにした。
// 5.submit先が同一の時にurl_suffixと、末尾の/(の連続)を強制的に取り除いた値で判定するように改造した。
// デフォルトだと 真偽チェック時のセッション側キーが、$_SERVER["REQUEST_URI"] に依存しているので、formのsubmit先が別のurlだとうまく動きません。
// この時は、フォームヘルパでform_csrf("複数画面にまたがる任意の同一なsuffix");と引数を指定することで、submit先が異なっても対応可能にしているつもり

// 6.問い合わせ処理の軽量化(クエリ発行回数を抑えた)
// 7.get_sessh() get_sessh_all() を追加し、tokenバリデーション付きで簡単にsession内にhidden扱いのデータ保持と取り出しを出来るようにした。

// @link 改造内容の説明1  http://d.hatena.ne.jp/dix3/20081017/1224196292
// @link 改造内容の説明2  http://d.hatena.ne.jp/dix3/20081111/1226350226
class Csrf {
  var $CI ;
  var $token_suffix = ""; //デフォルト$_SERVER["REQUEST_URI"]から url_suffixと、末尾の/(の連続)を強制的に取り除いた値
  var $token_prefix = "tkn_"; //トークンのprefix、毎回ランダムで生成しているので、この時に他のフォームの名前と偶然にぶつからないようにするため
  var $key_varname_base = "varname_";
  var $key_varname = "";
  var $key_value_base = "value_";
  var $key_value = "";
  var $key_message = "";
  var $key_sess_hidden_data = ""; //セッション扱いhiddenデータのキー
  var $key_sess_hidden_data_suffix = "_hidden"; //キーのサフィックス
  // var $error_message = "A form re-post or an unknown error has occurred.";
  var $error_message = "フォームが再送信されたか、送信時に不明なエラーが発生しました。再度送信してください。";
  // コンストラクタ
  function Csrf()
  {
    $this -> CI = &get_instance();
    if ( !isset( $this -> CI -> session ) || !is_object( $this -> CI -> session ) ) {
      // sessionが開始していないときのみ読み込む
      $this -> CI -> load -> library( 'session' );
    }
    $this -> CI -> load -> helper( 'security' ); //dohashを使えるようにするためだけ
  }
  // ワンタイムトークンの初期化
  function init( $suffix = "" )
  {
    $this -> _set_suffix ( $suffix );
  }
  // ワンタイムトークンのチェック、セッション内の値を即時破棄したくないときは $clean = falseで呼ぶ
  function check( $suffix = "", $clean = true )
  {
    $result = false; //デフォルトはfalse
    $this -> _set_suffix ( $suffix );

    if ( isset( $_POST ) && count( $_POST ) > 0 ) {
      if ( $this -> CI -> session -> userdata( $this -> key_varname ) && $this -> _is_token_valid() ) {
        $result = true; //ワンタイムトークン正常時のみtrueで返す
      }
    }else {
      // POSTが無ければtrueで戻しておく、破棄もしない
      $result = true;
    }
    if ( !$result ) {
      $this -> set_message( $this -> error_message , $suffix );
    }
    if ( $clean ) {
      // セッション内の値を破棄
      $this -> clean( $suffix );
    }
    return $result;
  }
  // ワンタイムトークンの破棄
  /**
   *
   * @method void clean() clears the session variables that store the csrf token
   */
  function clean( $suffix = "" )
  {
    if ( is_string( $suffix ) ) {
      $this -> _set_suffix ( $suffix );
      $this -> CI -> session -> unset_userdata( array( $this -> key_varname => '',
          $this -> key_value => '',
          $this -> key_sess_hidden_data => '' )
        );
    }else {
      if ( is_array( $suffix ) && $suffix ) {
        $arr = array();
        foreach( $suffix as $val ) {
          $this -> _set_suffix ( $val );
          $arr[$this -> key_varname] = '';
          $arr[$this -> key_value] = '';
          $arr[$this -> key_sess_hidden_data] = '';
        }
        $this -> CI -> session -> unset_userdata( $arr );
      }
    }
  }
  // ワンタイムトークンのエラーメッセージセット
  function set_message( $msg = "" , $suffix = "" )
  {
    $this -> _set_suffix ( $suffix );
    if ( $suffix ) {
      // $suffixが指定されていて遷移先が異なる場合は、flashdataを使ってみる
      $this -> CI -> session -> set_flashdata( $this -> key_message, $msg );
    }else {
      $this -> CI -> session -> set_userdata( $this -> key_message, $msg );
    }
  }
  // ワンタイムトークンのエラーメッセージ取得
  function get_message( $suffix = "" )
  {
    $this -> _set_suffix ( $suffix );

    if ( $suffix ) {
      // $suffixが指定されていて遷移先が異なる場合は、flashdataを使ってみる
      $msg = $this -> CI -> session -> flashdata( $this -> key_message ) ;
    }else {
      $msg = $this -> CI -> session -> userdata( $this -> key_message ) ;
      // 一度取得したエラーメッセージは消去する
      $this -> CI -> session -> unset_userdata( $this -> key_message );
    }
    return $msg;
  }
  // ワンタイムトークンの生成
  /**
   *
   * @method void set_token($varlen, $str_to_shuffer) construct a random input field name and assign the token to it.
   * @param int $varlen the length of the input field name that will be generated
   * @param string $str_to_shuffer the string that will be used to generate the input field name
   */
  function set_token( $varlen = 3, $str_to_shuffer = "abcdefghijklmnopqrstuvwxyz0123456789_", $sess_hidden_data = array() )
  {
    $start_pos = mt_rand( 0, ( strlen( $str_to_shuffer ) - $varlen ) );
    $varname_data = $this -> token_prefix . substr( str_shuffle( $str_to_shuffer ), $start_pos, $varlen );
    $value_data = dohash( microtime() . mt_rand() ) ;

    if ( $sess_hidden_data ) {
      // セッション内のhidden扱いデータの保存
      // check_token_key,check_token_valueは整合性チェック用
      $hidden_data = array( 'check_token_key' => $varname_data,
        'check_token_value' => $value_data,
        'data' => $sess_hidden_data );
      $this -> CI -> session -> set_userdata(
        array( $this -> key_varname => $varname_data ,
          $this -> key_value => $value_data,
          $this -> key_sess_hidden_data => $hidden_data )
        );
    }else {
      $this -> CI -> session -> set_userdata( array( $this -> key_varname => $varname_data , $this -> key_value => $value_data ) );
    }
  }
  /**
   *
   * @method string get_varname($varlen, $str_to_shuffer) return the generated input field name
   * @param int $varlen the length of the input field name that will be generated
   * @param string $str_to_shuffer the string that will be used to generate the input field name
   * @return string the generated input field name
   */
  function get_varname( $varlen = 3, $str_to_shuffer = "abcdefghijklmnopqrstuvwxyz0123456789_" )
  {
    $val = $this -> CI -> session -> userdata( $this -> key_varname );
    if ( ! $val ) {
      $this -> set_token( $varlen, $str_to_shuffer );
      $val = $this -> CI -> session -> userdata( $this -> key_varname );
    }
    return $val ;
  }

  /**
   *
   * @method string get_value($varlen, $str_to_shuffer) return the token
   * @param int $varlen the length of the input field name that will be generated
   * @param string $str_to_shuffer the string that will be used to generate the input field name
   * @return string the token
   */
  function get_value( $varlen = 3, $str_to_shuffer = "abcdefghijklmnopqrstuvwxyz0123456789_" )
  {
    $val = $this -> CI -> session -> userdata( $this -> key_value );
    if ( ! $val ) {
      $this -> set_token( $varlen, $str_to_shuffer );
      $val = $this -> CI -> session -> userdata( $this -> key_value );
    }
    return $val;
  }
  // セッション内のhidden扱いデータの取得(全部)
  function get_sessh_all( $suffix = "" )
  {
    return $this -> get_sess_hidden_data_all( $suffix );
  }
  function get_sess_hidden_data_all( $suffix = "" )
  {
    $this -> _set_suffix ( $suffix );

    $alldata = $this -> CI -> session -> all_userdata();

    $arr = isset( $alldata[$this -> key_sess_hidden_data] ) ? $alldata[$this -> key_sess_hidden_data] : array();
    $check_token_key = isset( $alldata[$this -> key_varname] ) ? $alldata[$this -> key_varname] : NULL;
    $check_token_value = isset( $alldata[$this -> key_value] ) ? $alldata[$this -> key_value] : NULL;

    if ( $check_token_key && $check_token_value &&
      isset( $arr['check_token_key'] ) && ( $arr['check_token_key'] == $check_token_key ) &&
        isset( $arr['check_token_value'] ) && ( $arr['check_token_value'] == $check_token_value ) ) {
      return $arr['data'];
    }else {
      return NULL;
    }
  }
  // セッション内のhidden扱いデータの取得(一部)
  function get_sessh( $suffix = "", $key = "" )
  {
    return $this -> get_sess_hidden_data( $suffix , $key );
  }

  function get_sess_hidden_data( $suffix = "", $key = "" )
  {
    if ( !$key ) {
      return NULL;
    }
    $data = $this -> get_sess_hidden_data_all( $suffix );
    return ( is_array( $data ) && isset( $data[$key] ) ) ? $data[$key] : '';
  }
  // ワンタイムトークンの整合性チェックの中身
  /**
   *
   * @method bool _is_token_valid() checks if a csrf token is valid
   * @return bool true if token is valid. false if token is invalid.
   */
  function _is_token_valid()
  {
    $result = false;
    $varname = $this -> get_varname();
    $value = $this -> get_value();
    if ( $varname ) {
      if ( isset( $_POST[$varname] ) ) {
        $result = ( ( strcmp( $value, $_POST[$varname] ) ) == 0 );
      }
    }
    return $result;
  }
  // $_SERVER["REQUEST_URI"]から url_suffixと、末尾の/(の連続)を強制的に取り除いた値を戻す
  function _get_request_uri()
  {
    $request_uri = preg_replace( "#" . $this -> CI -> config -> item( 'url_suffix' ) . "/*$#iu" , "" , $this -> CI -> input -> server( 'REQUEST_URI', true ) );
    $request_uri = rtrim( $request_uri, "/" );
    return $request_uri;
  }
  // suffix指定有りの時は指定された値、指定無しの時はREQUEST_URIを基にワンタイムトークンを生成
  function _set_suffix( $suffix = "" )
  {
    $this -> token_suffix = ( $suffix ) ? $suffix : $this -> _get_request_uri();
    $this -> key_varname = $this -> token_prefix . $this -> key_varname_base . $this -> token_suffix;
    $this -> key_value = $this -> token_prefix . $this -> key_value_base . $this -> token_suffix;
    $this -> key_message = $this -> token_prefix . $this -> token_suffix . "_msg" ; //セッションのキーが1次元しか持てないからこんなややこしい事になる
    $this -> key_sess_hidden_data = $this -> token_prefix . $this -> key_varname_base . $this -> token_suffix . $this -> key_sess_hidden_data_suffix ;
  }
} //endofclass
/**
 * End of file Csrf.php
 */
/**
 * Location: ./application/libraries/Csrf.php
 */
 ?>


2:ヘルパ application/helpers/MY_form_helper.php

<?php if ( !defined( 'BASEPATH' ) ) exit( 'No direct script access allowed' );
if ( ! function_exists( 'form_csrf' ) ) {
  /**
   *
   * @method string form_csrf($suffix, $varlen, $str_to_shuffer) returns a constructed hidden input field of the csrf token
   * @param int $varlen the length of the input field name that will be generated
   * @param string $str_to_shuffer the string that will be used to generate the input field name
   * @return string the hidden input field
   */
  function form_csrf( $suffix = "" , $sess_hidden_data = array() ,$params = array() )
  {

    $varlen = (is_array($params) && isset($params['len'])) ? $params['len'] : 6 ;
    $str_to_shuffer = (is_array($params) && isset($params['shuffer'])) ? $params['shuffer'] : "abcdefghijklmnopqrstuvwxyz0123456789_" ;

    $CI = &get_instance();
    if ( !isset( $CI -> csrf ) || !is_object( $CI -> csrf ) ) {
      // csrfがロードされていないときのみ読み込む
      $CI -> load -> library( 'csrf' );
    }
    $CI -> csrf -> init( $suffix );
    $CI -> csrf -> set_token( $varlen, $str_to_shuffer,$sess_hidden_data );
    $dbg = false; //デバグ用
    if ( $dbg ) {
      return "name:" . $CI -> csrf -> get_varname() . ",value:" . form_input( $CI -> csrf -> get_varname(), $CI -> csrf -> get_value() );
    }else {
      return form_hidden( $CI -> csrf -> get_varname(), $CI -> csrf -> get_value() );
    }
  }
}

/* End of file MY_form_helper.php */
/* Location: ./application/helpers/MY_form_helper.php */
 ?>


うーん、自己満足。

なんかCodeIgniterについて書く人が少ないみたいで、だらだら書くのもちょっと気が引けてきた。

フレームワークとしては、まさにその名の通りだとおもうんだけどなー。
やっぱりフレームよりレールの方が楽しいのかな?

とかいいつつあんまり気にしてないけど。どうせ次に調査する予定だし。