カテゴリー
Computer Development PHP Security Programming Secure Coding Security

PHPでCSRF対策を自動的に行う方法

(Last Updated On: 2018/08/13)

PHPでWebページにCSRF対策を追加するのは簡単です。全てのページにCSRF対策を追加する場合、ファイルを1つインクルードする以外、ほとんど何も行う必要がありません。

CSRF Protection for PHPの機能

  • 自動的にHTMLフォームやリンクにCSRFトークンを追加
  • CSRFトークンの有効期限を設定可能

まだまだ多くのアプリケーションが固定かつ有効期限を設定しないのCSRFトークンを使っています。この状態では一旦CSRFトークンを盗まれると、攻撃者にいつまででも攻撃される可能性があります。

このスクリプトの場合、有効期限付きのCSRFトークンをWebページに自動追加して防御します。

後で記載するようにPHP 7.1でoutput_rewrite_var()のバグを完全に直しました。なのでPHP 7.1以降の利用がお勧めです。

リポジトリと使い方

サンプルコードはgithubに公開しています。ライセンスはMITです。

https://github.com/yohgaki/php-csrf-protection

使い方は簡単で、srcディレクトリにあるcsrf_init.phpをインクルードし、Exceptionが発生した場合に対応するだけです。

try {
    // Update url_rewriter.tags to enable href rewrite.
    ini_set('url_rewriter.tags', 'form=,a=href');
    // Set up config values
    $GLOBALS['_CSRF_DISABLE_'] = false;
    $GLOBALS['_CSRF_EXPIRE_']  = 15; // 15 sec expiration
    $GLOBALS['_CSRF_RENEW_']   = 10; // 10 sec renewal before expiration
    $GLOBALS['_CSRF_SESSION_'] = true; // Use dedicated session for CSRF
    $blacklist = ['delete', 'add', 'edit']; // Set dangerous GET vars.
    // Whitelist is better. Use https://github.com/yohgaki/validate-php-scr
    require_once(__DIR__.'/../src/csrf_init.php');
} catch (Exception $e) {
    // Show nice CSRF token error message for production use.
    if (empty($_POST)) {
        echo '<a href="'.  csrf_get_uri($blacklist) .'">Click here to return page<a><br />';
        echo 'If this is not an access you intended, DO NOT CLICK above link!<br />';
        echo 'Return to <a href="index.php">home</a>. <br />';
    } else {
        echo csrf_get_form();
        echo '<a href="'.  csrf_get_uri($blacklist) .'">Click here to return page<a><br />';
        echo 'If this is not an access you intended, DO NOT CLICK above button nor link!<br />';
        echo 'Return to <a href="index.php">home</a>. <br />';
    }
    echo $e->getMessage();
    exit;
}

これでPHPから出力する全てのWebページがCSRF攻撃から防御できます。

出力バッファを使っているので、出力が始まる前に上記のコードを実行する必要があります。

PHP 7.1でoutput_rewrite_var()のバグを完全に直しました。この際、セッションモジュールと共有していた出力バッファを分離し、output_*()関数が独立した出力バッファを持つようにしています。更に、PHP 7.1より古いPHPではfrom以外にもa、iframeなど色々書き換えていたのをformのみに変更しています。

PHP 7.1未満でTranSIDを有効にすると問題になります。TranSIDを有効にしている方は少ないと思いますが、一応PHP 7.1以降での利用を推奨します。

※ このサンプルはhash_hkdf()を利用しているのでPHP 7.1以上が必要です。

サンプルコードの実行

実際に動作するサンプルコードは次の手順で実行できます。

$ git clone https://github.com/yohgaki/php-csrf-protection.git
$ cd php-csrf-protection/example
$ php -S 127.0.0.1:8888

この後、ブラウザで

http://127.0.0.1:8888/csrf_test.php

にアクセスします。最初のアクセスにはCSRFトークンが設定されていないので、必ずエラーになります。「Click here to return page」のリンクをクリックするとページが表示されます。

このページにはform内に隠しinput、href属性に変数が追加されていることを確認できます。

TEST LINKをクリックするとクエリ文字列にCSRFトークンが追加されています。これを変更すると例外が発生し、無効なリクエストになることも確認できます。

オンラインで試す場合、これが利用できます。

https://sample.ohgaki.net/php-csrf-protection/example/

コードの紹介

心臓部となるコードはとても簡単です。次の2つがCSRFトークンを生成、検証する関数です。

HKDFを使い、シードとなる$secret、設定された有効期限から強力なCSRFトークンを導出します。

参考:

https://github.com/yohgaki/php-csrf-protection/blob/master/src/csrf_protection.php

/**
 * Generate secure CSRF token
 *
 * @param string $secret Random secret string for key derivation.
 * @param int    $expire Expiration in seconds.
 * @param string $extra_info Extra info such as query parameters.
 *
 * @return string CSRF token.
 */
function csrf_generate_token($secret, $expire = 300, $extra_info = '')
{
    assert(is_string($secret) && strlen($secret) >= 32);
    assert(is_int($expire) && $expire >= 15);
    assert(is_string($extra_info));

    $salt = bin2hex(random_bytes(32));
    $expire += time();
    $key = bin2hex(hash_hkdf('sha256', $secret, 0, $extra_info."\0".$expire, $salt));
    $token = join("-", [$salt, $key, $expire]);
    assert(strlen($token) > 32);
    return $token;
}


/**
 * Validate CSRF token
 *
 * @param string $secret Random secret string for key derivation.
 * @param string $token  CSRF token generated by csrf_generate_token().
 * @param string $extra_info Optional extra info such as query parameters.
 *
 * @return bool or string  Returns TRUE for success, string error message for errors.
 */
function csrf_validate_token($secret, $token, $extra_info = '')
{
    assert(is_string($secret) && strlen($secret) >= 32);
    assert(is_string($token) && strlen($token) >= 32);
    assert(is_string($extra_info));

    if ($token === '') {
        return 'No token';
    }
    if (!is_string($token)) {
        return 'Attack - Non-string token';
    }
    $tmp = explode("-", $token);
    if (count($tmp) !== 3) {
        return 'Atatck - Invalid token';
    }
    list($salt, $key, $expire) = $tmp;
    if (empty($salt) || empty($key) || empty($expire)) {
        return 'Attack - Invalid token';
    }
    if (strlen($expire) != strspn($expire, '1234567890')) {
        return 'Attack - Invalid expire';
    }
    $key2 = bin2hex(hash_hkdf('sha256', $secret, 0, $extra_info."\0".$expire, $salt));
    if (hash_equals($key, $key2) === false) {
        return 'Attack - Key mismatch';
    }
    if ($expire < time()) {
        return 'Expired';
    }
    return true;
}

 

上記の関数を個別に利用することもできますが、csrf_init.phpを使うと自動的にCSRF対策できます。

https://github.com/yohgaki/php-csrf-protection/blob/master/src/csrf_init.php

<?php
/**
 * Sample CSRF protection script
 *
 * Simply include this file to add CSRF protection for all pages.
 * Function is not used intentionally to keep namespace clean.
 */
require_once(__DIR__.'/csrf_protection.php');

assert(is_null($GLOBALS['_CSRF_DISABLE_']) || is_bool($GLOBALS['_CSRF_DISABLE_']));
assert(is_null($GLOBALS['_CSRF_EXPIRE_']) || is_int($GLOBALS['_CSRF_EXPIRE_']));
assert(is_null($GLOBALS['_CSRF_RENEW_']) || is_int($GLOBALS['_CSRF_RENEW_']));
assert(is_null($GLOBALS['_CSRF_SESSION_']) || is_bool($GLOBALS['_CSRF_SESSION_']));

// Set these globals to adjust settings
// I choose to pollute $GLOBALS, you may choose whatever namespace
// to pollute. e.g. Constant.
$GLOBALS['_CSRF_DISABLE_'] = $GLOBALS['_CSRF_DISABLE_'] ?? false;
$GLOBALS['_CSRF_EXPIRE_']  = $GLOBALS['_CSRF_EXPIRE_'] ?? 300;
$GLOBALS['_CSRF_RENEW_']   = $GLOBALS['_CSRF_RENEW_'] ?? 60;
$GLOBALS['_CSRF_SESSION_'] = $GLOBALS['_CSRF_SESSION_'] ?? true;

if (!empty($GLOBALS['_CSRF_DISABLE_'])) {
    return;
}
if ($GLOBALS['_CSRF_SESSION_'] && session_status() !== PHP_SESSION_ACTIVE) {
    $orig_name = session_name('CSRFTK');
    session_start();
}

$_SESSION['CSRF_SECRET'] = $_SESSION['CSRF_SECRET'] ?? random_bytes(32);

$csrftk = $_POST['csrftk'] ?? $_GET['csrftk'] ?? '';
// WARNING: csrf_validate_token() returns TRUE or error message. Never do if ($valid)
$valid = csrf_validate_token($_SESSION['CSRF_SECRET'], $csrftk);
if ($valid !== true) {
    $token = csrf_generate_token($_SESSION['CSRF_SECRET'], $GLOBALS['_CSRF_EXPIRE_']);
    output_add_rewrite_var('csrftk', $token);
    throw new RuntimeException('CSRF Token validation error: '. $valid);
}

list($salt, $key, $expire) = explode('-', $csrftk);
if ($expire < time() + $GLOBALS['_CSRF_RENEW_']) {
    $csrftk = csrf_generate_token($_SESSION['CSRF_SECRET'], $GLOBALS['_CSRF_EXPIRE_']);
}
output_add_rewrite_var('csrftk', $csrftk);

if (!empty($orig_name)) {
    // Session is started by this code. Cleanup.
    session_commit();
    session_name($orig_name);
    unset($_SESSION);
}

 

自動的にCSRFトークンを追加しているのは output_add_rewrite_var() です。

恐らく多くのPHPユーザーは output_add_rewrite_var() を使ったことが無いと思います。この関数は出力したWebページデータを解析し、指定された変数を指定されたHTMLの要素に追加します。

HTML要素の指定はurl_rewriter.tagsで設定します。

tag_name=attribute_name

をカンマ区切りで指定します。

ini_set(‘url_rewriter.tags’, ‘form=,a=href’);

この定義は、formタグに隠しinputタグを追加し、aタグのherf属性のURLにパラメーターを追加します。例えば、form=actionとするとactionに指定されたURLにパラメーターを追加します。img=srcとするとimgタグのsrc属性のURLにパラメーターを追加します。

formタグの取り扱いだけが特別で隠しinputタグを追加します。他は指定したタグのURL属性にパラメーターを追加します。URL属性以外も指定できますが、img=idとし、imgタグのid属性の値が”banner”だとすると”banner?param_name=param_value”というあまり意味のない結果になります。

言葉で説明しても解りづらいのでコードで説明すると

ini_set(”url_rewriter.tags’, ‘img=id’);

をセットし、

output_rewrite_var(‘param_name’, ‘param_value’);

を実行すると、出力中の

<img id=”bunner”>

<img id=”bunner?param_name=param_value”>

になります。あまり無いとは思いますが、便利な時もあるかも知れません。

出力バッファのバグも色々直してきたので、output_*()関数の存在と使い方は追加された時から知っていました。しかし、PHP 7.1まで使うことをあまりお勧めできない関数でした。PHP 7.1で構造上の問題を直してやっと、使いましょう、と言える状態なりました。いつかこれを書こうと思っていたら2年以上経過してしまいました。

まとめ

自動的かつ有効期限付きの強力なCSRF対策をすることはPHPではとても簡単です。php.iniのauto_prepend_fileを使えば、コードの変更も必要ありません。内部用に作ってあるアプリケーションなどでCSRF対策をしていない物でも、コード変更なしで完璧にCSRF対策できます。

CSRF対策があるアプリケーションでも、漏れが心配な場合でコード検査をするのが面倒なときにも便利です。HTML FormでないGETリクエストも含め、全てCSRF対策できます。/admin のパス以下だけ追加のCSRF対策を入れる、といった使い方も可能です。1

ただし、HTTP Refererによりクエリ文字列からトークンが漏洩する場合があります。これはReferer-Policyとそれをサポートするブラウザを利用するか、HTTPSを利用してREFERERが送られないようにする必要があります。

色々なところで試してください。

参考:


  1. 内部ネットワークからインターネットを使う場合、そもそも外部のネットワークのWebサイトなどを利用しても内部ネットワークに対してCSRFやXSS攻撃が行えないような仕組みを利用すべきです。参考1参考2参考3