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が送られないようにする必要があります。
色々なところで試してください。
参考: