クロスサイト・リクエスト(他のWebサイトから自分のサイトへURLリンクやPOSTでアクセスする)を制限したいWebアプリケーションは結構あります。例えば、ホームルーターの管理ページを作る場合、クロスサイト・リクエストは有用などころか有害です。ホームルーターの管理ページにはクロスサイト・リクエストによる脆弱性が多数報告されています。
PHPの基本機能を使えば、クロスサイト・リクエストを簡単かつ丸ごと拒否することができます。CSRFやXSS1を完全かつ簡単に拒否する仕組みを作れます。
URL Rewriterの仕様
URL Rewriterを使えば、簡単にWebサイトをクロスサイト・リクエストから保護できます。
URL RewriterはURL(http://example.com/file.phpやHTMLフォーム)に自動的に任意の変数を追加します。追加するにはoutput_add_rewrite_var関数を利用します。
output_add_rewrite_var
(PHP 4 >= 4.3.0, PHP 5, PHP 7)
output_add_rewrite_var — URL リライタの値を追加する
説明 ¶
bool output_add_rewrite_var ( string$name
, string$value
)この関数は、URL リライト機構に新しい名前/値の組を追加します。 名前および値は、URL (GET パラメータとして) およびフォーム (hidden フィールドとして) で追加されます。これは、session.use_trans_sid で透過的 URL リライティングが有効になっている場合に セッション ID が渡される方法と同じです。
この関数の挙動は、php.ini パラメータ url_rewriter.tags および url_rewriter.hosts によって制御されます。
注意: もし出力バッファリングが有効になっていない場合、この関数を コールすると出力バッファリングが暗黙的に開始されます。
このマニュアルページはPHP 7.1の説明になっています。私がURL Rewriterのバグを修正し、仕様を拡張(url_rewriter.hosts設定、専用の出力バッファを追加)したので、最近英語ページを更新したのですが日本語ページも翻訳されています。素晴らしい!
PHP 7.1未満の場合、出力バッファがSessionのTrans SID(透過的セッションID – URLやPOSTに自動的にセッションIDを追加する機能)と共有され、INI設定(url_rewriter.tags)も共用しています。リライト用の出力バッファーが共用されているに注意してください。2
PHP 7.1以降はSession用のINI設定名が異り(session.trans_sid_tags, session.trans_sid_hosts)、利用する出力バッファもそれぞれ専用なので注意する必要がありません。
output_add_rewrite_var関数を使ったクロスサイト・リクエストの拒否
クロスサイト・リクエストを丸ごと拒否する仕組みはこうなっています。
- output_add_rewrite_var()でリクエストをバリデーションするトークンを追加する
- リクエストを受け付けるPHPプログラムでトークンをバリデーションする
たったこれだけです。(注意:PHP 7.1以降が必要です)
リクエストトークンの作成
<?php ob_start(); // URL RewriterのデフォルトはformのPOSTしかサポートしないので調整 // <a href=""> と <form action="">を書き変え ini_set('url_rewriter.tags', 'a=href,form=action'); session_start(); function generate_rtoken() { // リクエストトークンはセッションIDを元に生成し、セッションに保存 $_SESSION['rtokens'][] = sha1(session_id()); // 古いトークンは削除。 if (count($_SESSION['rtokens']) > 10) { $_SESSION['rtokens'] = array_slice($_SESSION['rtokens'], count($_SESSION['trokens']) - 10, 10); } } // session_regenerate_id()を呼ぶ時にはトークンも作成 function my_session_regenerate_id() { session_regenerate_id(); generate_rtoken(); $_SESSION['created'] = time(); } // セッションIDは定期的に更新 if (empty($_SESSION['created']) || time() > $_SESSION['created'] + 1800) { my_session_regenerate_id(); } // トークン変数をURL Rewriterに登録 output_add_rewrite_var('rtoken', end($_SESSION['rtokens'])); ?>
リクエストトークンのバリデーション
<?php ob_start(); // URL RewriterのデフォルトはformのPOSTしかサポートしないので調整 // <a href=""> と <form action="">を書き変え ini_set('url_rewriter.tags', 'a=href,form=action'); session_start(); // URLのトークンを設定 $rtoken = $_GET['rtoken'] ?? NULL ; unction check_rtoken() { foreach($_SESSION['rtoken'] as $tk) { // タイミング攻撃を防止するにはhash_equals()が必須 if (!hash_eqauls($rtoken, $tk)) { return; } } // クロスサイト攻撃を阻止 die('You have been attacked by cross site request!'); } check_rtoken(); ?>
どこかに少なくとも一箇所のエントリポイントが必要です。エントリポイントではトークンのバリデーションは省略します。エントリポイントはクロスサイト・リクエストを防止できないので、クロスサイト・リクエストでも問題ないページにします。エントリポイント以外でバリデーションすればサイト全体がクロスサイトリクエストから保護されます。
JavaScriptからのリクエスト
JavaScriptからリクエストする場合、サーバーにトークンを返すAPIを作って取得してはいけません。JavaScriptからリクエストする場合、クエリ文字列の中にあるrtokenを利用します。
こうしないとクロスサイト・リクエストを防止できません。
直ぐに使えるように改良
先程のサンプルコードは解りやすいように2つに分けましたが、一つにまとめてauto_prepend_fileで使えるようにします。(注意:PHP 7.1以降が必要です)
site_protect.php – auto_prepend_fileで読込む
<?php ob_start(); ////////////////// PHP CONFIG /////////////////// // URL RewriterのデフォルトはformのPOSTしかサポートしないので調整 // <a href=""> と <form action="">を書き変え ini_set('url_rewriter.tags', 'a=href,form=action'); // use_strict_mode=Onは必須 ini_set('session.use_strict_mode',1); // Cookieベースのセッション(オプショナル) ini_set('session.use_cookies', 1); ini_set('session.use_only_cookies', 1); ini_set('session.use_trans_sid', 0); ////////////////// BEGIN FUNCTIONS /////////////////// // セッションステータスをチェックしてセッションを開始 function my_session_start() { if (session_status() == PHP_SESSION_ACTIVE) { return; } session_start(); if (isset($_SESSION['deleted']) && $_SESSION['deleted'] + 600 < time()) { // 古いセッションへのアクセスは普通はあり得ない(ただし、ネットワークのレースコンディションは除く) trigger_error('Your session might have been stolen! Or are you using wireless network?', E_USER_ERROR); die(); } } // 新しいセッションIDの生成 function my_session_regenerate_id() { // セッションIDを生成する場合、古いIDはタイムスタンプを使って削除しなければならない $_SESSION['deleted'] = time(); // PHP 7.0以降のsession_regenerate_id()は古いセッションを保存する if (PHP_VERSION_ID < 70000) { session_commit(); session_start(); } session_regenerate_id(); // 新しいセッションにdeletedは要らない unset($_SESSION['deleted']); // session_regenerate_id()を呼ぶ時にはトークンも作成 generate_rtoken(); $_SESSION['created'] = time(); } // リクエストトークンをバリデーション function check_rtoken() { // URLのトークンを設定 $rtoken = isset($_GET['rtoken']) ? $_GET['rtoken'] : ''; foreach($_SESSION['rtokens'] as $tk) { // タイミング攻撃を防止するにはhash_equals()が必須 if (hash_equals($rtoken, $tk)) { return; } } // クロスサイト攻撃を阻止 trigger_error('You have been attacked by cross site request!', E_USER_ERROR); die(); } // リクエストトークンを生成 function generate_rtoken() { // リクエストトークンはセッションIDを元に生成し、セッションに保存 $_SESSION['rtokens'][] = sha1(session_id()); // 古いトークンは削除。 if (count($_SESSION['rtokens']) > 10) { array_splice($_SESSION['rtokens'], 0, count($_SESSION['trokens']) - 10); } } ////////////////// USER CONFIG /////////////////// // エントリポイントの設定 $entry_points = ['/index.php'=>1]; ////////////////// MAIN /////////////////// my_session_start(); // セッションIDは定期的に更新 if (empty($_SESSION['created']) || time() > $_SESSION['created'] + 1800) { my_session_regenerate_id(); } // エントリポイント以外はトークンをバリデーション if (!isset($entry_points[$_SERVER['SCRIPT_NAME']])) { check_rtoken(); } // トークン変数をURL Rewriterに登録 output_add_rewrite_var('rtoken', end($_SESSION['rtokens'])); //////////////////////注意事項////////////////////////// /* session_start(), session_regenerate_id()を利用するアプリケーションの場合、 それぞれ、my_session_start(), my_session_regenerate_id()に置換して利用する 必要があります。 */
index.php ー テスト用のエントリポイント
<a href="/index.php">index.php</a> <a href="/protected.php">protected.php</a>
protected.php – テスト用の保護されたスクリプト
<?php echo 'Protected script'; echo '<pre>'; var_dump($_SESSION);
GitHub
このブログのスクリプトとは少し異なりますが、GitHubに入れておきました。こちらはPHP 5.6/7.0でも動作します。
https://github.com/yohgaki/no-cross-site-requests
テスト
上記の3つファイルを適当なディレクトリに置き
php -S 127.0.0.1:8888
としてビルトインWebサーバーを起動します。
http://127.0.0.1:8888/index.php
にアクセスすると
index.php protected.php
と画面に表示されます。index.phpはホワイトリストで許可したファイルなので、トークン無しでもアクセスできます。
protected.phpのリンクをクリックと以下のようなURLになり、URLにトークンが自動的に付加されていることが判ります。
http://127.0.0.1:8888/protected.php?rtoken=fc7002cba9e61ddca169656ca36405d12179a409
rtokenを削除したり、改ざんしたりすると
Fatal error: You have been attacked by cross site request! in /tmp/site_protect.php on line 22
と表示されtokenを知らない第三者がアクセスできないことがわかります。第三者が罠ページを作成して、CSRF攻撃、XSS攻撃を行おうとしても出来なくなります。
まとめ
この仕組みはルーターの様なデバイス、開発環境で誰でもアクセスできるツールなどを守る為に便利です。クロスサイト攻撃から守る為にはログインなどの認証も必要ありません。内部ネットワークからアクセスしかできない場合、認証機能が無くても保護できます。
画像やその他のファイルも守りたい場合、イメージファイルなども全てPHPで処理し、クロスサイト・リクエストでない場合にのみファイルを送信すれば保護できます。
古いPHPでも同様にリクエストをバリデーションする仕組みを作れますが、できればPHP 7.1以降で利用することをお勧めします。またsession_start()を呼び出しているので、アプリケーションがsession_start()を呼んでいる場合、既にセッションがある、とE_NOTICEエラーが出ます。普通、session_regenerate_id()はアプリケーションが呼ぶので、新しいトークンの登録が行われません。多少の改造が必要になります。
フレームワークのアプリケーションなどではエントリポイントが1つになっている場合が多いでしょう。この場合、スクリプトを少し改造してREQUEST_URIなどを利用し、特定のURIだけ保護する、などの対応が必要です。
管理ツールもクラウド上にある、という場合はIPアドレスで制限する、HTTP認証でも何でも良いので認証を付けてエントリポイントを保護する、のどちらかをすれば良いです。
URLとHTML Formには1つ値(rtoken)が追加されます。厳格に入力バリデーションしている場合、バリデーションコードの調整も必要になります。
これらの点に注意が必要ですが、とても簡単により安全なプライベートサイトが作れることが解ったと思います。