Edited at
PhalconDay 24

Phalcon\Mvc\Micro のイベント処理順序とお手軽CSRF対策

More than 3 years have passed since last update.

Phalconは去年に一度少しだけ触ってみた程度の初心者で、業務ではSilex(というかPimpleとSymfonyコンポーネント)をよく使っています。

そういうわけで、Silexに機能的に似ている Phalcon\Mvc\Micro でのイベントの処理順序と、それを踏まえた上で手軽なCSRF対策について考えてみました。

動作確認した環境は以下の通りです。


  • Windows 8

  • PHP 5.6.3

  • Phalcon 1.3.4


イベントの種類と処理順序について

microアプリケーション特有のイベントとして、以下が定義されています。


  • beforeHandleRoute

  • beforeExecuteRoute

  • beforeNotFound

  • afterExecuteRoute

  • afterHandleRoute

ミドルウェアイベントとして、以下が定義されています。


  • before

  • after

  • finish

未定義ルートへのリクエストに対するハンドラとして、以下が定義されています。


  • notFound


検証ソース1

以下のソースで処理順序を検証します。

<?php

$di = new \Phalcon\DI();

$di->setShared('logger', function() {
return new \Phalcon\Logger\Adapter\File(
__DIR__ . DIRECTORY_SEPARATOR . 'test.log'
);
});

$di->setShared('router', function() use ($di) {
$router = new \Phalcon\Mvc\Router();
$router->setDI($di);
return $router;
});

$di->setShared('request', function() use ($di) {
$request = new \Phalcon\Http\Request();
$request->setDI($di);
return $request;
});

$di->setShared('response', function() use ($di) {
$response = new \Phalcon\Http\Response();
$response->setDI($di);
return $response;
});

$app = new \Phalcon\Mvc\Micro($di);

$manager = new \Phalcon\Events\Manager();

$manager->attach('micro', function($event, $app) {
$app->logger->log($event->getType());
});

$app->setEventsManager($manager);

$app->before(function() use ($app) {
$app->logger->log('before');
});

$app->after(function() use ($app) {
$app->logger->log('after');
});

$app->finish(function() use ($app) {
$app->logger->log('finish');
});

$app->notFound(function() use ($app) {
$app->logger->log('notFound');
$app->response->setStatusCode(404, 'Not Found');
$app->response->send();
});

$app->get('/', function() use ($app) {
$app->logger->log('index');
return $app->response;
});

$app->get('/forward', function() use ($app) {
$app->logger->log('forward');
$app->handle('/forwarded');
});

$app->get('/forwarded', function() use ($app) {
$app->logger->log('forwarded');
return $app->response;
});

$app->handle();


ルーティング成功時の処理順序

リクエストに対するルーティングの結果、有効なハンドラが存在した場合は以下の順序で処理されます。


  1. beforeHandleRouteイベント

  2. beforeExecuteRouteイベント

  3. beforeイベント

  4. ハンドラ実行

  5. afterExecuteRouteイベント

  6. afterイベント

  7. afterHandleRouteイベント

検証ソースに GET / した場合のログ

[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] beforeHandleRoute

[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] beforeExecuteRoute
[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] before
[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] index
[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] afterExecuteRoute
[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] after
[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] afterHandleRoute
[Sat, 20 Dec 14 22:09:04 +0900][DEBUG] finish

フレームワークの内部処理を追ったわけではないので、これ以上のことは説明できませんが、ハンドラ実行の前なのか後なのか、beforeやafterの前なのか後なのかは、それぞれ使い分けるに当たって重要ですね。


ルーティングエラー時の処理順序

リクエストに対するルーティングの結果、有効なハンドラが存在しなかった場合は以下の順序で処理されます。


  1. beforeHandleRouteイベント

  2. beforeNotFoundイベント

  3. notFoundハンドラ

検証ソースに GET /not-found した場合のログ

[Sat, 20 Dec 14 22:10:11 +0900][DEBUG] beforeHandleRoute

[Sat, 20 Dec 14 22:10:11 +0900][DEBUG] beforeNotFound
[Sat, 20 Dec 14 22:10:11 +0900][DEBUG] notFound

notFoundハンドラにおいて $app->response->setStatusCode(404, 'Not Found'); した上で $app->response->send(); しているため、レスポンスのステータスコードは 404 Not Found になります。

(なお、send()せず return return $app->response; とした場合はレスポンスが送出されず、結果としてステータス200が返されます。)

PhalconではSilexなどとは異なり、フレームワークで発生した例外を処理するためのerrorハンドラのような定義ポイントは用意されていません。

また、ルーティングにおいて "405 Method Not Allowed" に該当する例外は発生せず、普通にnotFoundハンドラが呼ばれます。

notFoundハンドラへと処理が移った後はbeforeもafterもfinishも呼ばれないこと、通常時とエラー時の両方で共通の後処理的なイベントも存在しないことも重要です。

例えばセキュリティのため "X-Content-Type-Options: nosniff" など通常時とエラー時の両方で共通のレスポンスヘッダをセットしたい場合があるかと思いますが、利用できるイベントはbeforeHandleRouteのみとなります。

なお、notFoundハンドラを定義しなかった場合は、beforeHandleRoute → beforeNotFound とイベントが呼ばれた後、Fatal error: Uncaught exception 'Phalcon\Mvc\Micro\Exception' with message 'The Not-Found handler is not callable or is not defined' in... のエラーで処理が停止します。


ハンドラから別のハンドラを呼んだ時の処理順序

ハンドラから別のハンドラに処理を転送(Silex(というかSymfony?)では「サブリクエスト」と呼ばれる機能)したい場合、 Phalcon\Mvc\Micro::handle() を呼ぶことで実現できます。

その場合は以下の順序で処理されます。


  1. 1つ目のbeforeHandleRouteイベント

  2. 1つ目のbeforeExecuteRouteイベント

  3. 1つ目のbeforeイベント

  4. 1つ目のハンドラ実行

  5. 2つ目のbeforeHandleRouteイベント

  6. 2つ目のbeforeExecuteRouteイベント

  7. 2つ目のbeforeイベント

  8. 2つ目のハンドラ実行

  9. 2つ目のafterExecuteRouteイベント

  10. 2つ目のafterイベント

  11. 2つ目のafterHandleRouteイベント

  12. 1つ目のafterExecuteRouteイベント

  13. 1つ目のafterイベント

  14. 1つ目のafterHandleRouteイベント

検証ソースに GET /forward した場合のログ

[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] beforeHandleRoute

[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] beforeExecuteRoute
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] before
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] forward
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] beforeHandleRoute
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] beforeExecuteRoute
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] before
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] forwarded
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] afterExecuteRoute
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] after
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] afterHandleRoute
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] finish
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] afterExecuteRoute
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] after
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] afterHandleRoute
[Sat, 20 Dec 14 22:27:11 +0900][DEBUG] finish

Phalcon\Mvc\Micro::handle() で他のハンドラを呼ぶたびに、beforeHandleRoute → beforeExecuteRoute → before → ハンドラ実行 → afterExecuteRoute → after → afterHandleRoute → finish が繰り返し呼ばれるわけです。

リクエスト直後の初回のイベント発生時、あるいはレスポンス送出直前の最後のイベント発生時だけ処理を実行したいケースもあると思いますが、APIドキュメントを読んだ限りではフレームワークとしてはそういう機能はサポートされていないようです。

ところで、これをnotFoundハンドラから実行してみるとどうなるでしょうか。

以下のように書き換えて…

<?php

$app->notFound(function () use ($app) {
$app->logger->log('notFound');
$app->response->setStatusCode(404, 'Not Found');
$app->handle('/forwarded');
});

検証ソースに GET /not-found した場合のログ

[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] beforeHandleRoute

[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] beforeNotFound
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] notFound
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] beforeHandleRoute
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] beforeExecuteRoute
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] before
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] forwarded
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] afterExecuteRoute
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] after
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] afterHandleRoute
[Sat, 20 Dec 14 22:35:22 +0900][DEBUG] finish

レスポンスのステータスコードは 404 Not Found になります。

予想通りでしょうか。


Phalcon\Securityとoutput_add_rewrite_var()を使ったお手軽CSRF対策

イベントの処理順序を確認したところで、Phalcon\Securityを使ってCSRF対策を導入してみます。

APIドキュメントを見たところ、セキュリティに関するユーティリティ的なクラスのようで、ハッシュやパスワード関連、乱数を生成するメソッドなどが実装されています。(中には静的メソッドもあります)

DIでの生成はこんな感じです。

<?php

$di = new \Phalcon\DI();

$di->setShared('security', function() use ($di) {
$security = new \Phalcon\Security();
$security->setDI($di);
return $security;
});

// …中略…

$app = new \Phalcon\Mvc\Micro($di);

Phalcon\Security は Phalcon\DI\InjectionAwareInterface を実装しているため、setDI()しています。

CSRF対策には以下のメソッドを利用します。


  • Phalcon\Security::getTokenKey() CSRFトークンの名前を返す

  • Phalcon\Security::getToken() CSRFトークンの値を返す

  • Phalcon\Security::getSessionToken() getToken()で生成したCSRFトークンの値をセッションから返す

  • Phalcon\Security::checkToken() getTokenKey()とgetToken()で生成したCSRFトークンの名前と値を検証する

ここからが「お手軽」とするところなのですが、output_add_rewrite_var() を使ってみます。

この関数を使うと、出力バッファリングが自動的に有効となり、「URL-Rewriter」ハンドラがHTMLを透過的に書き換えることで、AタグのHREF属性値に任意の名前と値を持つパラメータを追加したり、FORMタグに任意の名前と値を持つ隠しフィールドを出力できます。つまり、HTMLを触ることなくCSRFトークンを出力できるわけです。

(今では使う機会もほとんどない session.use_trans_sid 相当の動作です。)

また、CSRFトークンチェックをハンドラ実行前のイベントで行うことで、ハンドラ側のコードにも手を入れることなく実装できます。

もう一度通常時の処理順序を見ると…


  1. beforeHandleRouteイベント

  2. beforeExecuteRouteイベント

  3. beforeイベント

  4. ハンドラ

  5. afterExecuteRouteイベント

  6. afterイベント

  7. afterHandleRouteイベント

POSTリクエストに対するCSRFトークンチェックは早い段階で良さそうですが、output_add_rewrite_var()によるタグの書き換えはPHPの出力バッファリングを利用しているため、出力バッファリングが有効になる前にレスポンスが送出されてしまうと、この機能が働かなくなります。

今回はCSRFトークンを有効にしたい画面ではハンドラ内でレスポンスを送出しないという前提で、以下のように書きました。

<?php

$manager = new \Phalcon\Events\Manager();

$manager->attach('micro:beforeHandleRoute', function($event, $app) {
if ($app->request->getMethod() === 'POST') {
if ($app->security->checkToken() === false) {
throw new \RuntimeException('Invalid Request');
}
}
});

$manager->attach('micro:afterHandleRoute', function($event, $app) {
ini_set('url_rewriter.tags', 'form=');
output_add_rewrite_var($app->security->getTokenKey(), $app->security->getToken());
});

$app->setEventsManager($manager);

こういう流れです。


  1. beforeHandleRouteイベント…POST送信されたCSRFトークンをチェック

  2. beforeExecuteRouteイベント

  3. beforeイベント

  4. ハンドラ

  5. afterExecuteRouteイベント

  6. afterイベント

  7. afterHandleRouteイベント…フォームにCSRFトークンの隠しフィールドを追加(するよう指定)

最初のbeforeHandleRouteで、POSTリクエストかつCSRFトークンが一致しない場合は例外をスローします。

(実際には例外をキャッチしてエラー画面を返すことになると思いますが、今回は省略します。)

最後のafterHandleRouteで、output_add_rewrite_var()によりCSRFトークンを出力します。

書き換えるタグの種類は url_rewriter.tags で決まりますが、不要なタグを書き換えないようini_set()でformだけに絞っています。


検証ソース2

以下のソースで処理結果を検証します。

<?php

$di = new \Phalcon\DI();

$di->setShared('logger', function() {
return new \Phalcon\Logger\Adapter\File(
__DIR__ . DIRECTORY_SEPARATOR . 'test.log'
);
});

$di->setShared('router', function() use ($di) {
$router = new \Phalcon\Mvc\Router();
$router->setDI($di);
return $router;
});

$di->setShared('request', function() use ($di) {
$request = new \Phalcon\Http\Request();
$request->setDI($di);
return $request;
});

$di->setShared('response', function() use ($di) {
$response = new \Phalcon\Http\Response();
$response->setDI($di);
return $response;
});

$di->setShared('session', function() {
$session = new \Phalcon\Session\Adapter\Files();
$session->start();
return $session;
});

$di->setShared('security', function() use ($di) {
$security = new \Phalcon\Security();
$security->setDI($di);
return $security;
});

$app = new \Phalcon\Mvc\Micro($di);

$manager = new \Phalcon\Events\Manager();

$manager->attach('micro:beforeHandleRoute', function($event, $app) {
$app->logger->log($event->getType());
if ($app->request->getMethod() === 'POST') {
if ($app->security->checkToken() === false) {
$app->logger->log('checkToken() NG');
throw new \RuntimeException('Invalid Request');
}
$app->logger->log('checkToken() OK');
}
});

$manager->attach('micro:afterHandleRoute', function($event, $app) {
$app->logger->log($event->getType());
$name = $app->security->getTokenKey();
$value = $app->security->getToken();
ini_set('url_rewriter.tags', 'form=');
output_add_rewrite_var($name, $value);
$app->logger->log(sprintf('output_add_rewrite_var("%s", "%s")', $name, $value));
});

$app->setEventsManager($manager);

$app->map('/', function () use ($app) {
if ($app->request->getMethod() === 'POST') {
$app->logger->log('POST /');
} else {
$app->logger->log('GET /');
}
return $app->response->setContent(<<<HTML
<html>
<body>
<form method="post" action="/">
<input type="submit" value="POST" />
</form>
</body>
</html>
HTML

);
})->via(['GET', 'POST']);

$app->handle();


フォーム表示時の処理

CSRFトークンのチェックと挿入を有効にして、フォームを表示してみます。

検証ソースに GET / した場合のログ

[Mon, 22 Dec 14 12:54:52 +0900][DEBUG] beforeHandleRoute

[Mon, 22 Dec 14 12:54:52 +0900][DEBUG] GET /
[Mon, 22 Dec 14 12:54:52 +0900][DEBUG] afterHandleRoute
[Mon, 22 Dec 14 12:54:52 +0900][DEBUG] output_add_rewrite_var("ZwF1hqL3MVzzBEtf", "6300d95a7766663ab9fe363f01074a84")

レスポンスHTML

<html>

<body>
<form method="post" action="/"><input type="hidden" name="ZwF1hqL3MVzzBEtf" value="6300d95a7766663ab9fe363f01074a84" />
<input type="submit" value="POST" />
</form>
</body>
</html>

こんな風にフォーム画面のHTMLに隠しフィールドが挿入された結果、次のPOST送信でCSRFトークンチェックが行われるわけです。


フォーム送信時の処理

CSRFトークンが挿入されたフォームを送信してみます。

表示されたフォームからPOST送信した場合のログ

[Mon, 22 Dec 14 12:55:27 +0900][DEBUG] beforeHandleRoute

[Mon, 22 Dec 14 12:55:27 +0900][DEBUG] checkToken() OK
[Mon, 22 Dec 14 12:55:27 +0900][DEBUG] POST /
[Mon, 22 Dec 14 12:55:27 +0900][DEBUG] afterHandleRoute
[Mon, 22 Dec 14 12:55:27 +0900][DEBUG] output_add_rewrite_var("S19KusqTLFr01PoO", "f1fbeee3dfee1ed2d2c2c2c47420601d")

CSRFトークンチェックが行われたことと、再び新しいトークンのキーと値が発行されていることが分かります。

検証ソースの例では、POST送信を受け付けた後でリダイレクト(いわゆる「PRGパターン」)していないため、「F5」キーを押して再送信すると、CSRFトークンチェックが走って失敗します。

POST完了後に「F5」キーで再送信した場合のログ

[Sat, 22 Dec 14 12:56:12 +0900][DEBUG] beforeHandleRoute

[Sat, 22 Dec 14 12:56:12 +0900][DEBUG] checkToken() NG

今回は例外処理を行っていませんので、Fatal error: Uncaught exception 'RuntimeException' with message 'Invalid Request' in... のエラーで処理が停止します。

(PHPデフォルトのエラーハンドラがレスポンスを返しますので、ステータスコードは200になります。)


CSRF対策としてのワンタイムトークンの問題

このように Phalcon\Security のCSRFトークンは単一のワンタイムトークンとなっているため、フォームを複数のタブで開いた場合、最後に開いたフォーム以外は無効と判定されてしまいます。

Opera(Blink)等、ソース表示の際にリクエストを行うブラウザで、ソース表示後にフォームを送信した場合も同様です。

複数タブに対応した上でトークンに有効期限を持たせたいような場合、現状では何か別のライブラリに頼るか、独自の実装を行うしかなさそうです。


  • トークンは一度のチェックで無効とするか?(ワンタイムトークン)任意のタイミングで破棄するまでは同一の値を有効とするか?(固定トークン)

  • 同一セッションで複数タブを開いた場合の挙動をどうするか?一方をエラーとするか?それぞれ分岐した遷移とするか?(後者の場合は分岐の最大数に制限が必要?)

  • トークンの値をサーバからクライアントへどのような方法で渡して、クライアントからサーバへどのような方法で送信させるか?

自分で実装するにせよ、既存のライブラリを利用するにせよ、この辺りの要件は考えておく必要がありますね。

次回の課題として考えたいと思います。


参考リンク

Phalcon\Security によるCSRF対策の実装方法については、こちらの記事を参考にさせていただきました。

output_add_rewrite_var() によるCSRF対策については、以前に自分のブログでも書きました。

CSRF対策の基本的な考え方を知るには、こちらの記事が参考になります。

記事中でも触れられていますが、「固定トークン」の一種として紹介されることが多かったセッションIDやそのハッシュ値を使う方法は、今では否定的な意見が多いようです。

更に、HTMLソースは漏洩するものという前提で警鐘を鳴らされているのが以下の記事です。

また、少し古い記事ですが、こちらも必読だと思います。

こちらには関連リンクと要点が良く整理されています。