CodeIgniter4の公式認証ライブラリCodeIgniter Shieldを使う

CodeIgniter4の公式認証ライブラリCodeIgniter Shieldを使ってみます。 CodeIgniter Shieldは現在開発中です。

動作確認環境

  • CodeIgniter 4.2.0-dev (403a4c8)
  • CodeIgniter Shield dev-develop (6143def)
  • PHP 8.0.18
  • MySQL 5.7
  • macOS 10.15.7

インストール

CodeIgniter4のインストール

CodeIgniter 4.2.0-dev を使うため、ci4-app-template を使います。

$ composer create-project kenjis/ci4-app-template ci4-shield-test

CodeIgniter Shieldのインストール

ComposerでCodeIgniter Shieldをインストールします。

$ cd ci4-shield-test/
$ composer require codeigniter4/shield

データベースの作成

CREATE DATABASE `ci_shield` DEFAULT CHARACTER SET utf8mb4;
GRANT ALL PRIVILEGES ON ci_shield.* TO dbuser@localhost IDENTIFIED BY 'dbpasswd';

設定

CodeIgniter4の設定

以下のように設定を日本仕様などに変更しておきます。

--- a/app/Config/App.php
+++ b/app/Config/App.php
@@ -37,7 +37,7 @@ class App extends BaseConfig
      *
      * @var string
      */
-    public $indexPage = 'index.php';
+    public $indexPage = '';

     /**
      * --------------------------------------------------------------------------
@@ -70,7 +70,7 @@ class App extends BaseConfig
      *
      * @var string
      */
-    public $defaultLocale = 'en';
+    public $defaultLocale = 'ja';

     /**
      * --------------------------------------------------------------------------
@@ -97,7 +97,7 @@ class App extends BaseConfig
      *
      * @var string[]
      */
-    public $supportedLocales = ['en'];
+    public $supportedLocales = ['ja', 'en'];

     /**
      * --------------------------------------------------------------------------
@@ -109,7 +109,7 @@ class App extends BaseConfig
      *
      * @var string
      */
-    public $appTimezone = 'America/Chicago';
+    public $appTimezone = 'Asia/Tokyo';

     /**
      * --------------------------------------------------------------------------

.env

$ cp env .env

以下のように変更します。

CI_ENVIRONMENT = development

app.baseURL = 'http://localhost:8080/'

database.default.hostname = localhost
database.default.database = ci_shield
database.default.username = dbuser
database.default.password = dbpasswd
database.default.DBDriver = MySQLi
database.default.DBPrefix =

Configファイル

以下の設定ファイルを app/Config/ フォルダにコピーします。

  • vendor/codeigniter4/shield/src/Config/Auth.php
  • vendor/codeigniter4/shield/src/Config/AuthGroups.php

コピーしたファイルを更新します。

--- a/app/Config/Auth.php
+++ b/app/Config/Auth.php
@@ -1,6 +1,6 @@
 <?php

-namespace CodeIgniter\Shield\Config;
+namespace Config;

 use CodeIgniter\Config\BaseConfig;
 use CodeIgniter\Shield\Authentication\Actions\ActionInterface;
@@ -9,8 +9,9 @@ use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens;
 use CodeIgniter\Shield\Authentication\Authenticators\Session;
 use CodeIgniter\Shield\Authentication\Passwords\ValidatorInterface;
 use CodeIgniter\Shield\Models\UserModel;
+use CodeIgniter\Shield\Config\Auth as ShieldAuth;

-class Auth extends BaseConfig
+class Auth extends ShieldAuth
 {
     /**
      * ////////////////////////////////////////////////////////////////////
--- a/app/Config/AuthGroups.php
+++ b/app/Config/AuthGroups.php
@@ -1,10 +1,11 @@
 <?php

-namespace CodeIgniter\Shield\Config;
+namespace Config;

 use CodeIgniter\Config\BaseConfig;
+use CodeIgniter\Shield\Config\AuthGroups as ShieldAuthGroups;

-class AuthGroups extends BaseConfig
+class AuthGroups extends ShieldAuthGroups
 {
     /**
      * --------------------------------------------------------------------

ヘルパー設定

--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -39,6 +39,8 @@ abstract class BaseController extends Controller

     public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger): void
     {
+        $this->helpers = array_merge($this->helpers, ['auth', 'setting']);
+
         // Do Not Edit This Line
         parent::initController($request, $response, $logger);

Services設定

デフォルトのCodeIgniter4の場合は、この手順は必要ありません。

vendor/codeigniter4/shield/src/Config/Services.php に定義されている以下のメソッドを app/Config/Services.php にコピーします。

    /**
     * The base auth class
     */
    public static function auth(bool $getShared = true): Auth
    {
        if ($getShared) {
            return self::getSharedInstance('auth');
        }

        $config = config('Auth');

        return new Auth(new Authentication($config));
    }

    /**
     * Password utilities.
     */
    public static function passwords(bool $getShared = true): Passwords
    {
        if ($getShared) {
            return self::getSharedInstance('passwords');
        }

        return new Passwords(config('Auth'));
    }

codeigniter4/settings も Services を定義していましたので、それも追加します。

    /**
     * Returns the Settings manager class.
     */
    public static function settings(?SettingsConfig $config = null, bool $getShared = true): Settings
    {
        if ($getShared) {
            return static::getSharedInstance('settings', $config);
        }

        /** @var SettingsConfig $config */
        $config = $config ?? config('Settings');

        return new Settings($config);
    }

ルーティング設定

Shield用のルートを追加します。

--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -36,6 +36,8 @@ $routes->set404Override();
 // route since we don't have to scan directories.
 $routes->get('/', 'Home::index');

+service('auth')->routes($routes);
+
 /*
  * --------------------------------------------------------------------
  * Additional Routing

フィルター設定

ひとまず、トップページにフィルターを設定します。ログインが必要なように、session フィルターを指定します。

--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -35,7 +35,7 @@ $routes->set404Override();

 // We get a performance increase by specifying the default
 // route since we don't have to scan directories.
-$routes->get('/', 'Home::index');
+$routes->get('/', 'Home::index', ['filter' => 'session']);

 service('auth')->routes($routes);

ルーティングの確認

ルートを確認します。

$ php spark routes

CodeIgniter v4.1.9 Command Line Tool - Server Time: 2022-05-26 17:04:35 UTC+09:00

+--------+-------------------------+--------------------------------------------------------------------+----------------------+-------------------------------+
| Method | Route                   | Handler                                                            | Before Filters       | After Filters                 |
+--------+-------------------------+--------------------------------------------------------------------+----------------------+-------------------------------+
| GET    | /                       | \App\Controllers\Home::index                                       | session invalidchars | session secureheaders toolbar |
| GET    | register                | \CodeIgniter\Shield\Controllers\RegisterController::registerView   | invalidchars         | secureheaders toolbar         |
| GET    | login                   | \CodeIgniter\Shield\Controllers\LoginController::loginView         | invalidchars         | secureheaders toolbar         |
| GET    | login/magic-link        | \CodeIgniter\Shield\Controllers\MagicLinkController::loginView     | invalidchars         | secureheaders toolbar         |
| GET    | login/verify-magic-link | \CodeIgniter\Shield\Controllers\MagicLinkController::verify        | invalidchars         | secureheaders toolbar         |
| GET    | logout                  | \CodeIgniter\Shield\Controllers\LoginController::logoutAction      | invalidchars         | secureheaders toolbar         |
| GET    | auth/a/show             | \CodeIgniter\Shield\Controllers\ActionController::show             | invalidchars         | secureheaders toolbar         |
| POST   | register                | \CodeIgniter\Shield\Controllers\RegisterController::registerAction | invalidchars csrf    | secureheaders toolbar         |
| POST   | login                   | \CodeIgniter\Shield\Controllers\LoginController::loginAction       | invalidchars csrf    | secureheaders toolbar         |
| POST   | login/magic-link        | \CodeIgniter\Shield\Controllers\MagicLinkController::loginAction   | invalidchars csrf    | secureheaders toolbar         |
| POST   | auth/a/handle           | \CodeIgniter\Shield\Controllers\ActionController::handle           | invalidchars csrf    | secureheaders toolbar         |
| POST   | auth/a/verify           | \CodeIgniter\Shield\Controllers\ActionController::verify           | invalidchars csrf    | secureheaders toolbar         |
| CLI    | ci(.*)                  | \CodeIgniter\CLI\CommandRunner::index/$1                           |                      |                               |
+--------+-------------------------+--------------------------------------------------------------------+----------------------+-------------------------------+

DBマイグレーション

マイグレーションを実行して必要なテーブルを作成します。

$ php spark migrate --all
CodeIgniter v4.1.9 Command Line Tool - Server Time: 2022-05-26 16:52:53 UTC+09:00

すべての新しいマイグレーションを実行しています...
    実行中: (CodeIgniter\Shield) 2020-12-28-223112_CodeIgniter\Shield\Database\Migrations\CreateAuthTables
    実行中: (CodeIgniter\Settings) 2021-07-04-041948_CodeIgniter\Settings\Database\Migrations\CreateSettingsTable
    実行中: (CodeIgniter\Settings) 2021-11-14-143905_CodeIgniter\Settings\Database\Migrations\AddContextColumn

以下のテーブルが作成されました。

auth_groups_users
auth_identities
auth_logins
auth_permissions_users
auth_remember_tokens
migrations
settings
users

migrations は CodeIgniter4 のデータベースマイグレーション用、settings は codeigniter4/settings 用のテーブルです。

Webサーバーの起動

$ php spark serve

ユーザー登録

ユーザー登録ページ

http://localhost:8080/register にアクセスします。

以下で登録します。

    メール:admin@example.jp
ユーザー名:admin
パスワード:passw0rd!

登録が完了すると、ログイン状態となり、トップページにリダイレクトされます。

http://localhost:8080/logout にアクセスするとログアウトします。

ログイン

ログインページ

http://localhost:8080/ にブラウザでアクセスします。

http://localhost:8080/login にリダイレクトされました。

先ほど作成したユーザーでログインします。

    メール:admin@example.jp
パスワード:passw0rd!

ログインが成功すると、トップページにリダイレクトされます。

右下のアイコンからデバッグツールバーを表示させて、Sessionデータを見ることで、ログイン状態であることがわかります。

http://localhost:8080/logout にアクセスするとログアウトします。

参考

Tags: codeigniter, codeigniter4, database, auth

CodeIgniterとLaravelをざっとベンチマークしてみる

環境

  • macOS 10.15.7
  • PHP 8.0.18
  • symfony server (Symfony CLI) 5.4.8

ベンチマーク対象

  • CodeIgniter 4.1.9
  • Laravel 9.10.0

インストール

$ composer create-project codeigniter4/appstarter codeigniter
$ cd codeigniter/
$ composer update --no-dev
$ composer create-project laravel/laravel laravel
$ cd laravel/
$ composer update --no-dev

ベンチマークの実行

CodeIgniter

$ cd codeigniter/
$ symfony server:start
$ ab -c 10 -t 3 http://127.0.0.1:8000/
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Finished 350 requests


Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        18227 bytes

Concurrency Level:      10
Time taken for tests:   3.000 seconds
Complete requests:      350
Failed requests:        32
   (Connect: 0, Receive: 0, Length: 32, Exceptions: 0)
Total transferred:      6438216 bytes
HTML transferred:       6379416 bytes
Requests per second:    116.66 [#/sec] (mean)
Time per request:       85.719 [ms] (mean)
Time per request:       8.572 [ms] (mean, across all concurrent requests)
Transfer rate:          2095.67 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:    27   84  13.7     82     147
Waiting:       26   82  13.8     81     147
Total:         27   84  13.7     82     148

Percentage of the requests served within a certain time (ms)
  50%     82
  66%     86
  75%     90
  80%     93
  90%    101
  95%    108
  98%    120
  99%    130
 100%    148 (longest request)
 ```

#### Laravel

 ```
$ cd laravel/
$ symfony server:start
$ ab -c 10 -t 3 http://127.0.0.1:8000/
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Finished 133 requests


Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        17593 bytes

Concurrency Level:      10
Time taken for tests:   3.024 seconds
Complete requests:      133
Failed requests:        0
Total transferred:      2485238 bytes
HTML transferred:       2339869 bytes
Requests per second:    43.98 [#/sec] (mean)
Time per request:       227.394 [ms] (mean)
Time per request:       22.739 [ms] (mean, across all concurrent requests)
Transfer rate:          802.48 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       3
Processing:    56  210  54.1    206     463
Waiting:       56  209  54.1    206     462
Total:         57  210  54.1    206     463

Percentage of the requests served within a certain time (ms)
  50%    205
  66%    225
  75%    232
  80%    236
  90%    262
  95%    281
  98%    306
  99%    454
 100%    463 (longest request)

結果

Requests per second
CodeIgniter 4.1.9 116.66
Laravel 9.10.0 43.98

おまけ

du コマンドによるディスク使用量の比較。

5.4M codeigniter/
 37M laravel/

Tags: codeigniter, codeigniter4, laravel, benchmark

浅いモデリングによる脆弱性、あるいはCodeIgniter4の自動ルーティングは何が問題だったのか?

この記事は「CodeIgniter 4.1.8 以前に存在する脆弱性」と関連はしますが、たとえ脆弱性が修正されたバージョンを使っていても該当することを記載しています。

この記事で伝えたいこと

  • 浅いモデリングとは何か
  • 浅いモデリングにより、どのようにCSRF保護が回避される脆弱性が発生するのか
  • CodeIgniterでの正しいCSRF対策

浅いモデリングとは

浅いモデリング(shallow modeling)とは、『セキュア・バイ・デザイン』にある用語で、「その場しのぎのモデリング」のことです。 「開発者がうまくモデリングできたと最初に思った時点で、それ以上は深く考えたり疑問を抱いたりすることをやめてしまう」ことです。

このような場合、開発者がどのようにコードを書くのかさえわかれば、設計作業は終了します。言い換えると、コードが動きさえすればOKということで、対象となる概念を正しく捉えてモデリングできているかは問題にはなりません。

浅いモデリングを一言で言い表すなら、「緩い」あるいは「厳密でない」となります。

例えば、ショッピングカートの商品の数量に int 型を使うようなモデリングです。PHPでは、64bit環境では、int は 9,223,372,036,854,775,807(922京)から -9,223,372,036,854,775,808(-922京)までです。冷静に考えれば、いくら何でもそんなに大きな数は必要ないですし、マイナスの整数も不要でしょう。

そして、そのような浅いモデリングは、重要な概念がコードで正しく表現されていないために、バグや脆弱性を作り込んでしまうリスクが高くなります。

この記事では、浅いモデリングがどのように脆弱性を産み出したかの一例を解説します。

CodeIgniter4の自動ルーティング

CodeIgniter4の自動ルーティングとは、URLと実行されるコントローラのメソッドを以下のように対応させる機能のことです。CodeIgniterバージョン1からの機能です。

http://example.jp/コントローラ名/メソッド名[/引数1[/引数2[/...]]]

例えば、URLが http://example.jp/home/index/abc/123 の場合、Home コントローラの index() メソッドが、以下のようなイメージで実行されます。

$controller = new Home();
$controller->index('abc', '123');

はい、わかりやすいですね。

URLとコントローラメソッドが1対1に対応しています。何の問題があるのでしょうか?

自動ルーティングの問題点

この自動ルーティングの問題点は大きくは以下の2つです。

  1. HTTPのリクエストメソッドが考慮されていない
  2. 1つのコントローラメソッドに対応するURLが複数になる

この記事では (1) のリクエストメソッドが考慮されていない点について見ていきます。

(2) については、先ほど、URLとコントローラメソッドが「1対1」対応と書きましたが嘘です。 正しくは、URLとコントローラメソッドは「多対1」です。この (2) について興味のある方は、 【改訂版】本当は危ないCodeIgniter4の自動ルーティング をご覧ください。

PHPスクリプトとリクエストメソッド

まず最初に、PHPスクリプトとリクエストメソッドの関係を見ておきましょう。

検証のために以下のスクリプトを用意します。

index.php:

<?php
echo 'REQUEST_METHOD: ' . $_SERVER['REQUEST_METHOD'] . "<br>\n";
echo 'SERVER_SOFTWARE: ' . $_SERVER['SERVER_SOFTWARE'];

上記のスクリプトに対して、 GETリクエストを送信してみましょう。

$ curl http://localhost/index.php
REQUEST_METHOD: GET<br>
SERVER_SOFTWARE: nginx/1.20.1

REQUEST_METHODが GET となっています。

POSTリクエストを送信してみましょう。

$ curl -X POST http://localhost/index.php
REQUEST_METHOD: POST<br>
SERVER_SOFTWARE: nginx/1.20.1

同じようにスクリプトが実行されています。 違いは、REQUEST_METHODが POST になったことだけです。

PUTリクエストを送信してみましょう。

$ curl -X PUT http://localhost/index.php
REQUEST_METHOD: PUT<br>
SERVER_SOFTWARE: nginx/1.20.1

同じようにスクリプトが実行されています。

それではOPTIONSリクエストを送信してみましょう。

$ curl -X OPTIONS http://localhost/index.php
REQUEST_METHOD: OPTIONS<br>
SERVER_SOFTWARE: nginx/1.20.1

やっぱり同じようにスクリプトが実行されるだけです。

ちなみにResponseヘッダーを確認しても、OPTIONSリクエストの正当な応答にはなっていません。

$ curl -i -X OPTIONS HTTP/1.1 200 OKdex.php
Server: nginx
Date: Fri, 11 Feb 2022 01:09:14 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Encoding
X-Powered-By: PHP/7.4.23
Expires: Fri, 11 Feb 2022 01:09:14 GMT
Cache-Control: max-age=0
X-Frame-Options: SAMEORIGIN
X-UA-Compatible: IE=edge
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block

REQUEST_METHOD: OPTIONS<br>
SERVER_SOFTWARE: nginx/1.20.1

最後にCATメソッドでリクエストを送信してみましょう。

$ curl -X CAT http://localhost/index.php
REQUEST_METHOD: CAT<br>
SERVER_SOFTWARE: nginx/1.20.1

驚くべきことに同じです。HTTPにCATメソッドなどいうメソッドは標準では定義されていませんが。

つまり、PHPはリクエストメソッドがなんであろうと、WebサーバーからリクエストされればPHPスクリプトを実行するだけです。

まあ、これは当たり前のことで、そもそもリクエストメソッドに応じた処理を、開発者がPHPスクリプトに記述する必要があります

リクエストメソッドに応じた処理を書くことは開発者の責任です。OPTIONSメソッドに対応した処理が index.php に書かれていないため、OPTIONSリクエストを受けても正しいレスポンスが返らないわけです。

CodeIgniterの自動ルーティング

上記のように、もともとPHPスクリプトを平書きしていた頃から、PHPユーザーはあまりリクエストメソッドを気にしていませんでした。 そもそもGETとPOSTくらいしか使いませんでしたし、$_GET$_POST を区別して使えば問題ありませんでした。

CodeIgniterの自動ルーティングもそんな感覚で設計されたのではないかと想像します。これが浅いモデリングです。

CodeIgniterでも上記の index.php と同じように、Webサーバーからリクエストされればどんなリクエストメソッドであろうと対応するコントローラのメソッドが実行されます。

しかし、実際には、HTTPにおいてリクエストメソッドは重要な概念であり、モデルから抜けていいようなものではありません。 実際にリクエストメソッドにはそれぞれ意味があり、それに応じた適切な処理をして返す必要があります。

それでは、この自動ルーティングがどのように脆弱性を産み出したかを見ていきましょう。

CSRF保護回避の脆弱性

CSRF保護の仕様

CSRF保護においては、POST、PUT、PATCH、DELETEメソッドをCSRFから保護する必要があるとされています。 これは、それらのメソッドがリソースを書き換えるからです。

逆にGETなどのメソッドはリソースを書き換えない安全なメソッドと定義されており、保護する必要は特にありません。

仮にCSRFによりユーザーが重要なページにGETでアクセスさせられたとしても、何か表示されるだけでそれを見るのもその攻撃されたユーザーだけで、データが書き換えられたり破壊されることはないはずだからです。

従って、CodeIgniter4の実装も、POST、PUT、PATCH、DELETEメソッドの場合にのみ、トークンをチェックしてCSRFから保護するようになっています。

つまり、GETメソッドなどの上記以外のリクエストは、機能を有効にしたつもりでも、実際には全く保護されないということです。

しかし、安全なメソッドでリクエストされた際に何をするかは、PHPを書く開発者の責任です。GETリクエストなら安全なのではなく、GETリクエストに対しては安全な処理をするように開発者がコードを書かないといけないわけです。

次に、このCSRF保護が回避されてしまう脆弱性を産み出しす 2つの材料を見ていきましょう。

コントローラロジックの分離

もともとCSRF対策において、保護すべきなのは、重要なデータ加工処理であり、それを実行するコントローラメソッドです。

ですから、当初は、コントローラのメソッドにCSRF保護のロジックを記述していました。

CodeIgniter v1でのコード例:

        $this->ticket = $this->session->userdata('ticket');
        if (
            ! $this->input->post('ticket') 
            || $this->input->post('ticket') !== $this->ticket 
        ) {
            echo 'クッキーを有効にしてください。クッキーが有効な場合は、不正な操作がおこなわれました。';
            exit;
        }

重要な処理を特定し、そのメソッドの最初でCSRF対策のトークンをチェックするのです。これなら、チェックと重要な処理がくっついており問題ありませんでした。

しかし、時代が進むにつれ、コントローラの肥大化が問題となり、コントローラのロジックがどんどん分離されるようになりました。

CSRF対策もその1つであり、具体的には、CodeIgniter4ではコントローラフィルターという機能に分離されています。つまり、別のクラスにロジックは移動してしまい、コントローラのメソッドにはCSRF対策のコードは影も形もありません。

CodeIgniter4のCSRF保護を有効にするためには、フィルターの設定ファイルで設定するか、ルーティングの設定でそのルートに対するフィルターを設定する必要があります。

フィルターの設定例:

    public $globals = [
        'before' => [
             'csrf',
        ],
        'after' => [],
    ];

この結果、コントローラ内の重要な処理の直前にあったCSRF保護のコードは、コントローラから完全に分離され、設定によってコントローラに対応づけられるという弱い対応関係になりました。

コントローラ処理の抽象化

もう 1つ、これはかなり以前から行われていたかも知れませんが、GETでもPOSTでもどちらのリクエストが来ても同じコントローラのコードで処理できるように記述するというコードの書き方があります。

もともと、PHPには $_REQUEST 変数がありますし、$_POST を検索し、なければ $_GET から値を取得するというメソッドがフレームワークで提供されていることもあります。

GET/POSTだけでなく、JSONによるPUTなどからも同一のメソッドでデータを取得できたりします。こういうメソッドを使えば、1つのコントローラメソッドでいろいろなリクエストメソッドのリクエストを処理できます。便利ですね。

CSRF保護の回避方法

さて、脆弱性を産み出しす 2つの材料がそろいましたので、具体的な回避方法に進みます。

CSRF保護機能の仕様により、この保護を回避するためには、リクエストメソッドをGETなどチェックされないものにすればいいだけです。

つまり、通常POSTで送信されるべきURLに対して、CSRFでユーザーにGETリクエストを送信させればチェックは実行されません。

ここで、そのGETリクエストを受けたコントローラメソッドがGETの場合でもPOSTと同じように処理を実行できる抽象化されたコードになっていれば、CSRFによる攻撃が成功します。そして、自動ルーティング機能は、GETでもPOSTでもリクエスト可能にしてくれます。

つまり、攻撃方法は以下のようになります。

  • 罠サイトを作り、ユーザーを誘導し、重要な処理をするURLへGETなど保護されないメソッドでリクエストを送信させる

そのURLが開発者の想定外のリクエストメソッドでも処理を実行可能なら、CSRFによる攻撃が成功します。

これは何の脆弱性か?

これはアプリケーションの脆弱性でありフレームワークの脆弱性ではありません。

なぜなら、CSRF保護機能がGETリクエストを保護しないのは仕様であり、自動ルーティングがGETリクエストを受け付けることも仕様だからです。

言い換えると、CSRF保護対象の重要な処理が、保護されるリクエストメソッドでのみリクエスト可能にするのは開発者の責任ということになります。

つまり、開発者は、もともと以下のいずれかをする必要があったのです。

  1. そのコントローラメソッドにPOST/PUT/PATCH/DELETE以外のリクエストが絶対に来ないように設定する
  2. POST/PUT/PATCH/DELETEメソッド以外のメソッドでリクエストが来た場合は重要な処理を実行しないようにする

自動ルーティングを使っている限り、1 は不可能なので、必然的に 2 を行う必要があります。

実際に、以下のようなコードをコントローラメソッドの先頭に記載していれば、CSRFによる攻撃は成功しません。

        if (strtolower($this->request->getMethod()) !== 'post') {
            return $this->response->setStatusCode(405)->setBody('Method Not Allowed');
        }

また、例えば、POSTでの処理を前提としているコントローラメソッドで、$_POST$_POST の値を取得するCodeIgniter4の $this->reqest->getPost() メソッドを使って、開発者が想定するHTTPメソッドの場合のみデータを取得できるようなコードを書いていれば、GETリクエストが送信された場合には、必要なデータが取得できず重要な処理が検証エラーで実行されず、CSRFによる攻撃は成功しません。

メンタルモデルと現実の解離

現実には、HTTPにおいて、リクエストメソッドは非常に重要な概念であるにも関わらず、自動ルーティングにはその概念が表現されていませんでした。 現実と実装が解離しています。

その結果、開発者のメンタルモデルからもリクエストメソッドが抜け落ちることを助長します。

開発者はコントローラのコードを書く際に、リクエストメソッドに対応した適切な処理を記述しなければならないにも関わらす、リクエストメソッドを意識せずにコードを書いてしまいます。また、抽象化した再利用しやすい汎用的なコードを書いてしまい、結果として脆弱性を産み出します。

あるいは、CSRF保護はフレームワークが全部やってくれると思っており、何も考えていないということもあるかも知れません。

このような経緯で、脆弱性が生まれやすい環境ができました。

例えば、 リクエストが GET http://example.jp/home/index/abc/123 の場合、Home コントローラの getIndex() メソッドを実行するという仕様だったら、リクエストメソッドが絶えず意識されたことでしょう。

CodeIgniterでの正しいCSRF対策

CodeIgniterでの正しいCSRF対策は以下のいずれかを必ず実装することです。

  1. そのコントローラメソッドが、想定するリクエストメソッド(通常はPOSTまたはPUT/PATCH/DELETE)のリクエスト以外では絶対に実行されないように設定する
  2. 想定するリクエストメソッド以外のメソッドでリクエストが来た場合は重要な処理を実行しないようにする

CodeIgniter4では、1 は自動ルーティングをオフに設定し、リクエストメソッドに対応するルートを全て手動設定すれば可能です。 具体的には、$routes->add() メソッドを使わずに、$routes->get()$routes->post() でルートを設定します。

そのように全ルートを手動で設定していれば、コントローラのメソッドが想定しているリクエストメソッドと実際にルーティングされるリクエストメソッドが解離する可能性は極めて低いです。

POSTで受けるつもりのコードを書いていて、仮に誤って $routes->get() でルートを設定してしまったとしても、正常系の処理がうまく動作せず、容易に気がつくからです。

逆に自動ルーティングでは、POSTで受けるつもりのコードを書いていても、自動的にGETリクエストでもアクセス可能です。 POSTするHTMLフォームを作成し、処理がうまく動作すればOKだと思うでしょう。念のためGETに変えて処理されないかテストする開発者はほぼいないと思われます。

CodeIgniter3では、1 は機能がなく無理なので、2 を実装します。なお、CodeIgniter3のCSRF保護はPOSTメソッドしか保護しません。

まとめ

  • コードが動きさえすればOKというその場しのぎのモデリングを「浅いモデリング」と言います。
  • 浅いモデリングでは脆弱性を作り込むリスクが高くなります。
  • CodeIgniterの「自動ルーティング」は浅いモデリングでした。
  • 自動ルーティングには重要概念であるリクエストメソッドが欠落しており、その結果、CSRF保護が回避される脆弱性が作られやすい状況を助長しました。
  • CodeIgniter4では自動ルーティングはオフに設定し、$routes->add() も使わないようにしましょう。
  • CSRF保護対象となるコントローラメソッドでは、リクエストメソッドが想定されたものか確認しましょう。

参考

Tags: codeigniter, codeigniter4, security