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