2011年6月2日木曜日

PHPセッションにセキュリティ対策する

 PHPには標準でセッションの管理機能が組み込まれているので平易に使うことができるけれど、これがなかなか曲者。そのまま使ってダメな理由はここが詳しくて、ざっくり書くと下記の通り。
 ※そもそもセッションIDの書式チェックがされてない。
 ※初期設定ではCookieにsecure属性がついてない。
 ※SSLに移行しても同じIDを使い続ける。
ここらへんは設定の問題や、回避処理を忘れずに入れておけば問題ないのだけど、HTTPからHTTPS、HTTPSからHTTPへのセッションの移行とかが必要になるとちょっと別口の方法を用意しておかないと簡単には実現できない。ようわ、
1.HTTP中をセッションID12345で移動しているとして、
2.SSLに移行時には同じIDが危険なので67890に変更する
3.ただ、CookieにsecureフラグがついているのでHTTPに戻った時に67890が取得できないので
4.HTTPとHTTPSの両方で保持しておきたい情報はセッションには入れられない
5.よって、都度渡すか別途データベース等に外部的に保存しておくしかない
(大嘘だったらゴメンナサイ)
そこらへんもろもろの対策と外部DBではなくCookieに保存するようにしたのが下記の通り。(CSRF対策と冗長な注意事項と言い訳のおまけ付き)

<?php
/* strict_session.php (セキュアなセッション管理)
 *
 * - PHP5.1.0以上じゃないと使えない
 * - 携帯には対応してない。Cookieに対応していない端末やUA、IPが
 *   ころころ変ることがある為。
 * - session_idは特定のルールで設定し、書式チェックを行う
 * - ハッシュ生成はsha1を使うように
 * - また、SSLかはポートで判別してるのでProxyの裏側でも使えない
 * - セッションの破棄はstrict_session_destroy()を使ってhttpとhttpsで
 *   それぞれやる必要がある
 * - セッションIDの変更時には元のセッションを破棄
 * - Cookieのパスや有効期間は都度設定する必要があるかもしれない
 * - HTTPSに移動したらセッション名とIDを変更
 * - Cookie以外でセッションのやりとりをしない(trans_sidをOFF)
 * - UAとIPが一致しなかったら新規セッションとする
 * - そのままだとHTTPのセッションはHTTPSで使えないが、明示的に
 *   strict_session_serializeを使うことで値の受渡しが可能。
 * - POSTで渡さない場合は外部DB化することが必要
 * - まとめると
 *   HTTPとHTTPSの両方から参照したいデータ: カートのデータ等
 *     -> 外部DB化して、両方から参照できるようにする
 *   HTTPSのみのデータ: 個人情報系
 *     -> HTTPSのセッションに保存する(HTTPには決っして飛ばない)
 * - 外部DBをCookieで代替、あまり大きいデータは保持できない
 */

// UAを保存するセッション名
define('STRICT_SESSION_UA_NAME','strict_session_user_agent');

// IPアドレスを保存するセッション名
define('STRICT_SESSION_RA_NAME','strict_session_remote_addr');

// HTTP <-> HTTPS 間で受渡しされるセッション名
define('STRICT_SESSION_ENCRYPT_NAME','strict_session_serialized');

// HTTP <-> HTTPS 間で受渡しされる暗号化キー文字列
// 外部に漏れると中身が見られてしまう恐れがある。

// サイト毎に変更すること
define('STRICT_SESSION_ENCRYPT_KEY','keuiqdzR87m4X7xDF39sSqoH');

// CSRF対策用のセッション名
define('STRICT_SESSION_CSRFM_NAME','strict_session_csrfm');

/*
 * セキュアなセッションを開始
 */
function strict_session_start() {

    // セッションはCookieのみを使用
    ini_set( 'session.use_only_cookies', 1 );
    // md5よりさらに予測困難なsha1でセッションIDを生成
    ini_set( 'session.hash_function', 1 );
    // セッションIDをURLパラメータに書かない
    ini_set( 'session.use_trans_sid', 0 );
    // HTTP <-> HTTPS 間でセッションの受渡しやCSRF対策でフォームに
    // 自動付与されるoutput_add_rewrite_varでURLパラメータに書かれ
    // ないようにする
    ini_set( 'url_rewriter.tags', 'form=' );

    // HTTPS通信時にはCookieにセキュア属性をつけ、セッションの名前
    // も変更する
    if($_SERVER['SERVER_PORT']==443){
        ini_set( 'session.cookie_secure', 1 );
        session_name('SECURE_'.session_name());
    }

    // セッションの開始
    session_start();

    $invalid=FALSE;

    // sha1でセッションIDのハッシュ値を生成した場合の書式チェック
    if(! preg_match('/[0-f]{40}/', session_id())){
        $invalid=TRUE;
    }
    
    // IPとUAが同時に一致しなかった時のみエラーとする
    if(
       (
        $_SERVER['HTTP_USER_AGENT'] && $_SESSION[STRICT_SESSION_UA_NAME] &&
        ($_SERVER['HTTP_USER_AGENT'] != $_SESSION[STRICT_SESSION_UA_NAME])
        )
       &&
       (
        $_SERVER['REMOTE_ADDR'] && $_SESSION[STRICT_SESSION_RA_NAME] &&
        ($_SERVER['REMOTE_ADDR'] != $_SESSION[STRICT_SESSION_RA_NAME])
        )
       ){
        $invalid=TRUE;
    }
    
    $_SESSION[STRICT_SESSION_UA_NAME]=$_SERVER['HTTP_USER_AGENT'];
    $_SESSION[STRICT_SESSION_RA_NAME]=$_SERVER['REMOTE_ADDR'];
    
    if($invalid){
        // セッションを破棄して新しいセッションを開始
        strict_session_destroy();
    }
    
}

/*
 * セッションIDを変更して元のIDを削除する。
 * - PHP 5.1.0 以降でのみ正しく動作
 */
function strict_session_regenerate_id(){
    return session_regenerate_id(TRUE);
}

/*
 * セッションを完全に削除
 */
function strict_session_destroy(){
    // セッション変数を全て解除する
    $_SESSION = array();

    // セッションを切断するにはセッションクッキーも削除する。

    // Note: セッション情報だけでなくセッションを破壊する。
    if (isset($_COOKIE[session_name()])) {
        setcookie(session_name(), '', time()-42000, '/');
    }

    // HTTP <-> HTTPS 間でセッションの受渡し用Cookieも削除
    if (isset($_COOKIE[STRICT_SESSION_ENCRYPT_NAME])) {
        setcookie(STRICT_SESSION_ENCRYPT_NAME, '', time()-42000, '/');
    }

    // 最終的に、セッションを破壊する
    session_destroy();
}

/*
 * HTTP <-> HTTPS 間でセッションの受渡し用関数
 * 与えられた変数をシリアライズして、STRICT_SESSION_ENCRYPT_KEYで定義した
 * キーを元に暗号化。
 */
function strict_session_serialize($d=null) {
    $iv = mcrypt_create_iv(mcrypt_get_iv_size(MCRYPT_3DES, MCRYPT_MODE_CFB), MCRYPT_RAND);
    setcookie(STRICT_SESSION_ENCRYPT_NAME, base64_encode($iv.mcrypt_encrypt(MCRYPT_3DES, STRICT_SESSION_ENCRYPT_KEY, serialize($d), MCRYPT_MODE_CFB, $iv)), 0, '/');
}

/*
 * HTTP <-> HTTPS 間でセッションの受渡し用関数
 * strict_session_serializeによって暗号化され変数を復号化する。

 */
function strict_session_unserialize() {

    if(isset($_COOKIE[STRICT_SESSION_ENCRYPT_NAME])){
        $is = mcrypt_get_iv_size(MCRYPT_3DES, MCRYPT_MODE_CFB);
        $et = base64_decode($_COOKIE[STRICT_SESSION_ENCRYPT_NAME]);
        return unserialize(mcrypt_decrypt(MCRYPT_3DES, STRICT_SESSION_ENCRYPT_KEY, substr($et,$is), MCRYPT_MODE_CFB, substr($et, 0, $is)));
    }

    return FALSE;
    
}

/*
 * CSRF対策: ID(トークン)を生成してフォームに付与する
 * - output_add_rewrite_varによってページの全てのFORMに自動付与される。
 *   外部サイトへのフォームにも付与されてしまうので注意。
 */
function strict_session_csrfm_set() {
    $_SESSION[STRICT_SESSION_CSRFM_NAME]=strict_session_csrfm_id();
    output_add_rewrite_var(STRICT_SESSION_CSRFM_NAME,$_SESSION[STRICT_SESSION_CSRFM_NAME]);
}

/*
 * CSRF対策: ID(トークン)を比較して違っていたらFALSEを返す
 * POSTのみ対応。
 */
function strict_session_csrfm_check() {

    // セッションに保存されたIDとPOSTされたIDを比較。
    if($_POST[STRICT_SESSION_CSRFM_NAME] && $_POST[STRICT_SESSION_CSRFM_NAME] == $_SESSION[STRICT_SESSION_CSRFM_NAME]){
        return TRUE;
    }
    
    return FALSE;
    
}

/*
 * CSRF対策: ID(トークン)の生成
 */
function strict_session_csrfm_id() {
 mt_srand((double)microtime()*1000000);
    return sha1(uniqid(mt_rand(),1));
}

?>