jQueryでJSONをPOSTしてFuelPHPで受け取る

JSONでデータをPOST

jQueryでJSON形式でデータをPOSTして、結果もJSONで返るという場合です。

Ajax通信のJavaScriptは以下のようになります。

    var data = {
        name: "はじめてのフレームワークとしてのFuelPHP"
    };

    $.ajax({
        type : 'post',                      // HTTPメソッド
        url  : '/json/post.json',           // POSTするURL
        data: JSON.stringify(data),         // POSTするJSONデータ
        contentType: 'application/json',    // リクエストのContent-Type
        dataType: 'json',                   // レスポンスのデータ型
        success: function(json_data) {      // 成功時の処理
            return;
        },
        error: function() {                 // HTTPエラー時の処理
            alert('Server Error. Please try again later.');
        },
        complete: function() {              // 完了時の処理
        }
    });

この場合、以下のようなリクエストが送信されます(ヘッダは一部のみ記載)。

Content-Type: application/json; charset=UTF-8
X-Requested-With: XMLHttpRequest

{"name":"はじめてのフレームワークとしてのFuelPHP"}

FuelPHPでこの値を取得するには、Input::json('name')を使います。

普通にPOST

JSONではなく普通にkey1=val1&key2=val2の形式でPOSTする場合は、以下のようになります。

    var data = {
        name: "はじめてのフレームワークとしてのFuelPHP"
    };

    $.ajax({
        type : 'post',                      // HTTPメソッド
        url  : '/json/post.json',           // POSTするURL
        data: data,                         // POSTするデータ
        dataType: 'json',                   // レスポンスのデータ型
        success: function(json_data) {      // 成功時の処理
            return;
        },
        error: function() {                 // HTTPエラー時の処理
            alert('Server Error. Please try again later.');
        },
        complete: function() {              // 完了時の処理
        }
    });

この場合、以下のようなリクエストが送信されます(ヘッダは一部のみ記載)。

Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest

name=%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AE%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%83%AF%E3%83%BC%E3%82%AF%E3%81%A8%E3%81%97%E3%81%A6%E3%81%AEFuelPHP

FuelPHPでこの値を取得するには、普通にPOSTされた場合と同じくInput::post('name')を使います。

なお、この違いは、PHPに基づくものです。PHPの$_POSTkey1=val1&key2=val2のようなデータしか取得できません。

Tags: jquery, ajax, fuelphp

独自ヘッダをチェックするだけのステートレスなCSRF対策は有効なのか?

WebAPIのステートレスなCSRF対策」という2011-12-04の記事がありました。

ここで説明されているCSRF対策は、

  • GET、HEAD、OPTIONSメソッドのHTTPリクエストはCSRF保護の対象外
  • HTTPリクエストにX-Requested-Byヘッダがなければエラーにする

という非常にシンプルなものです。

そして、この対策の原理として以下の説明がありました。

form, iframe, imageなどからのリクエストではHTTPリクエストに独自のヘッダを付与することができません。独自のヘッダをつけるにはXMLHttpRequestを使うしかないわけです。そしてXMLHttpRequestを使う場合にはSame Origin Policyが適用されるため攻撃者のドメインからHTTPリクエストがくることはない、ということのようです。

ここで、

XMLHttpRequestを使う場合にはSame Origin Policyが適用されるため攻撃者のドメインからHTTPリクエストがくることはない

という記述があります。これはXHR Level1では正しい記述ですが、XHR Level2では正確ではありません。

XHR Level2では、クロスオリジンのリクエストは可能だからです。クロスオリジンのリクエストはどのサーバにも可能で到達します。しかし、CORSによりサーバ側で許可されていないクロスオリジンのリクエストの結果(レスポンス)はJavaScriptからはアクセスできません(ブラウザにより捨てられる)。

CSRFでは、リクエストがサーバに到達し処理されれば攻撃は成功します。レスポンスは必要ありません。

ということで、実際にどうなるかを検証してみます。

検証方法

「攻撃対象ページ」と「罠ページ」を作成し、「罠ページ」にアクセスするとXHRで「攻撃対象ページ」にAjaxでPOSTします。このとき、リクエストにX-Requested-Withヘッダを追加します。

元記事ではX-Requested-Byヘッダですが、より一般的と思われるX-Requested-Withに変更しています。どちらも独自ヘッダであり、対策としての効果に違いは生じません。

「攻撃対象ページ」は、何のCSRF対策もせずにログを記録するだけにします。

詳細はこの記事の最後に記載します。

検証結果

X-Requested-Withヘッダを追加したため、ブラウザが「プリフライトリクエスト」を発行しました。

プリフライトリクエストとは、以下のようなヘッダを含むOPTIONメソッドでのリクエストです。クロスオリジンのリクエストが許可されているかを確認するためのものです。特定の条件によりブラウザが自動的に発行します。

Origin: http://csrf-trap.herokuapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: x-requested-with

この場合、サーバではこのようなクロスオリジンのリクエストを許可していないので、実際のX-Requested-Withヘッダ付きのリクエスト(攻撃のためのリクエスト)は発行されません。

ということで、そもそもCSRFのリクエストが(プリフライトリクエストのみしか)発行されず、(CSRF対策をしていないにもかかわらず)CSRF攻撃は失敗しました。

結果

攻撃失敗(プリフライトリクエストのみ)

  • Firefox 36.0 (Linux)
  • Firefox 36.0 (Mac OS X)
  • Chrome/41.0.2272.89 (Linux)
  • Safari 8.0.4 (Mac OS X)
  • IE 11.0 (Windows 7)

この結果からは、独自ヘッダをチェックするだけのCSRF対策は、サーバ側で攻撃者のドメインからのリクエストを許可していない限り、有効だということになります。

このCSRF対策を実際に使うべきか?

ということで、独自ヘッダをチェックするだけの対策も、上記のブラウザでは、有効であることが確認されました。

上記の記事に関する徳丸さんのツイートがありましたので、以下に引用しておきます。

ということで、徳丸さんの去年のご意見では、独自ヘッダをチェックするだけのCSRF対策は推奨しないということでした。

検証方法の詳細

「攻撃対象ページ」と「罠ページ」を作成します。

攻撃対象ページ

まず、リクエストを受ける攻撃対象ページを作成します。

POSTメソッドでリクエストされた場合に、ログに記録するだけです。何のCSRF対策もしていません。

GETされた場合は、ログを表示するようにします。

<?php

if (! function_exists('getallheaders')) {
    function getallheaders()
    {
        $headers = [];
        foreach ($_SERVER as $name => $value) {
            if (substr($name, 0, 5) === 'HTTP_') {
                $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
            } elseif ($name === 'CONTENT_TYPE') {
                $headers['Content-Type'] = $value;
            } elseif ($name === 'CONTENT_LENGTH') {
                $headers['Content-Length'] = $value;
            }
        }
        return $headers;
    }
}

$id = $_GET['id'];

if (preg_match('/\A[a-f0-9]{64}\z/', $id) !== 1) {
    exit('Invalid Request');
}

$log = __DIR__ . '/logs/'. $id;
$method = $_SERVER['REQUEST_METHOD'];

if ($method === 'GET') {
    echo '<pre>' . "\n";
    if (file_exists($log)) {
        echo htmlspecialchars(file_get_contents($log));
    } else {
        echo 'No request.';
    }
    echo '</pre>' . "\n";
} else {
//    $_POST = [
//        'comment' => '<s>This is a comment.</s>'
//    ];
    $data = $_POST;
    if (! isset($data['comment'])) {
        $data['comment'] = '';
    }
    if ($data['comment'] === '') {
        $output = 'CSRF attack was failed.' . "\n";
    } else {
        $output = 'CSRF attack was succeeded.' . "\n";
    }

    $time = date(DATE_ATOM);
    $output .= sprintf('[%s] %s "%s" %s' . "\n", $time, $method, $data['comment'], $id);
    foreach (getallheaders() as $name => $value) {
        $output .= "$name: $value\n";
    }
    $output .= "\n";

    file_put_contents($log, $output, LOCK_EX | FILE_APPEND);
}

これで、攻撃対象ページは完成です。

罠ページ

次に、罠ページを作成します。攻撃したい人をこの罠ページにアクセスさせれば、CSRFによる攻撃が実行されます。

jQueryで自動的にAjaxでPOSTするようにします。X-Requested-With: XMLHttpRequestというヘッダを追加するように指定しています。

<?php
$id = hash('sha256', openssl_random_pseudo_bytes(32));
$url = 'https://csrf-target.herokuapp.com/target/?id=' . $id;
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>CSRF attack via Ajax</title>
        <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
        <script>
            $(function () {
                function post() {
                    $.ajaxSetup({
                        crossDomain: true,
                        xhrFields: {
                            withCredentials: true
                        },
                        headers: {'X-Requested-With': 'XMLHttpRequest'}
                    });

                    $.post(
                        '<?php echo $url; ?>',
                        {comment: 'This is CSRF attack via Ajax'}
                    )
                }

                post();
            });
        </script>
    </head>

    <body>
        <p>CSRF attack via Ajax</p>
        <p><?php echo date(DATE_ATOM); ?></p>
        <p>See <a href="<?php echo $url; ?>">results</a>.</p>
    </body>
</html>

これで、完了です。面倒なので、攻撃したとかそういう親切な表示はありません。現在時刻だけ表示してます。

試したい人へ

すぐに試せるように、罠ページをhttp://csrf-trap.herokuapp.com/ajax-post-x-requested-with/に作成しました。

ということで、攻撃されてみたい人はhttp://csrf-trap.herokuapp.com/ajax-post-x-requested-with/にアクセスし、その後、「See results.」のリンクをクリックしてください。

攻撃が成功していれば、1行目に「CSRF attack was succeeded.」、2行目にCSRFでPOSTしたメッセージ「This is CSRF attack via Ajax」が表示されます。

関連

Tags: ajax, csrf, security

【改訂版】FuelPHPのAgentクラスが重い問題と暫定的な対処方法

FuelPHPのAgentクラスが重い問題と暫定的な対処方法」の改訂版です。

問題の所在

FuelPHP 1.xには、ブラウザ種別、バージョン、プラットフォーム、OSなどを取得するAgentクラスがありますが、その処理が重い、メモリ消費が大きいという問題です。

Agentクラスが使用するブラウザ情報が記録された「browscap.iniファイル」が大きくなったことに比例して、処理が重くなります。

詳細は、FuelPHPのAgentクラスが重い問題と暫定的な対処方法を参照してください。

前回は、browscap/browscap-phpを使う方法を書きましたが、その後も「browscap.iniファイル」が大きくなっており、更新時のメモリ消費が半端なく大きくなってしまっています。

対処方法

今回、新たに別のライブラリを使います。

3. Crossjoin\Browscapを使う

Crossjoin\Browscapは、「browscap.iniファイル」を利用してエージェントの判定をするライブラリです。

高速であり、メモリ消費を抑えた実装になっています。

現状では、Welcomeコントローラのaction_index()メソッドにecho Agent::browser();を追加すると、browscap-phpでは以下のようになりました(Mac OS Xでの結果)。

Page rendered in 0.0709s using 8.934mb of memory.

これが、Crossjoin\Browscapでは、以下のように改善されました。

Page rendered in 0.0656s using 0.59mb of memory

さらに詳細なライブラリのベンチマーク結果は以下を参照してください。

インストール方法

まず、Crossjoin\BrowscapをComposerでインストールしてください。

--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,8 @@
         "fuel/parser": "dev-1.8/develop",
         "fuelphp/upload": "2.0.2",
         "monolog/monolog": "1.5.*",
-        "michelf/php-markdown": "1.4.0"
+        "michelf/php-markdown": "1.4.0",
+        "crossjoin/browscap": "1.0.*"
     },
     "require-dev": {
         "fuel/docs": "dev-1.8/develop"

Agentクラスへの暫定的な修正パッチは以下になります。

--- a/classes/agent.php
+++ b/classes/agent.php
@@ -195,8 +195,33 @@ class Agent
            // try the build in get_browser() method
            if (ini_get('browscap') == '' or false === $browser = get_browser(static::$user_agent, true))
            {
+             // if it fails, use Crossjoin\Browscap
+             $cacheDir = APPPATH.'cache/fuel/agent';
+             \Crossjoin\Browscap\Cache\File::setCacheDirectory($cacheDir);
+             \Crossjoin\Browscap\Browscap::setDatasetType(\Crossjoin\Browscap\Browscap::DATASET_TYPE_SMALL);
+             $updater = new \Crossjoin\Browscap\Updater\None();
+             \Crossjoin\Browscap\Browscap::setUpdater($updater);
+             $browscap = new \Crossjoin\Browscap\Browscap();
+             $browser = (array) $browscap->getBrowser(static::$user_agent)->getData();
+             // fix any type issues
+             foreach ($browser as $var => $value)
+             {
+                 if (is_numeric($value))
+                 {
+                     $browser[$var] = $value + 0;
+                 }
+                 elseif ($value == 'true')
+                 {
+                     $browser[$var] = true;
+                 }
+                 elseif ($value == 'false')
+                 {
+                     $browser[$var] = false;
+                 }
+             }
+
                // if it fails, emulate get_browser()
-               $browser = static::get_from_browscap();
+//               $browser = static::get_from_browscap();
            }

            if ($browser)

この場合も、cronなどで以下の処理を実行し、browscap.iniファイルを別途更新する必要があります。

タスクは以下のようになります。

<?php

namespace Fuel\Tasks;

class Agent
{
    public static function update()
    {
        $cacheDir = APPPATH.'cache/fuel/agent';
        \Crossjoin\Browscap\Cache\File::setCacheDirectory($cacheDir);
        \Crossjoin\Browscap\Browscap::setDatasetType(\Crossjoin\Browscap\Browscap::DATASET_TYPE_SMALL);
        $browscap = new \Crossjoin\Browscap\Browscap();
        $settings = $browscap->getBrowser()->getData();
    }
}

また、Crossjoin\Browscapは、更新時のメモリ消費も抑えるような実装になっており、 browscap-phpで381,943,808バイト必要だったものが、Crossjoin\Browscapでは62,652,416バイト(約1/6)で済みました(CentOS 6.5 64bitでの結果)。

まとめ

get_browser()関数互換のbrowscap.iniを使ったユーザのブラウザ判定には、Crossjoin\Browscapをお薦めします。

関連

Tags: fuelphp