ZendFrameworkで作る『イマドキ』のWebアプリケーション

第4回ゲストブックの改善

予告:4/30にZendFramework 1.8がリリースされました。次回はZendFramework 1.8の新機能の紹介とZendFramework 1.8を使って、ゲストブックアプリを作り直します。

機能的な問題以外ににも、前回作った簡単なゲストブックを使ってみるといろいろな問題があることが分かります。

  • 日本語が使えない
  • エスケープのセキュリティ対策が十分でない
  • ZendFrameworkらしくないViewでのエスケープ

しかし、簡単なゲストブックアプリケーションを⁠普通⁠に作る場合とZendFrameworkを使って作る場合の違いを比べると、アプリケーションの作り方の違いがよく分かったと思います。

ZendFrameworkを使ったほうが、手順が多くて面倒に感じたかも知れません。しかし、それはアプリケーションが単純すぎてフレームワークのメリットを感じられなかっただけです。アプリケーションの規模が大きくなり、複雑になるとフレームワークのメリットが感じられるようになります。

データベースの設定修正

まずはデータベースの設定を修正します。データベースには2つの問題があります。

  • 文字エンコーディング
  • ユーザアカウント

文字エンコーディングの問題

どんなアプリケーションでも利用する文字エンコーディングを正しく取り扱わないと、セキュリティ上の問題となります。特にWebアプリケーションでは文字エンコーディングを利用し、SQLインジェクションやJavaScriptインジェクションなどを行うさまざまな攻撃方法が知られています。

今回はPostgreSQLをデータベースサーバとして利用しています。PostgreSQLはMySQLやMS SQL Server、Orableなどと同様に、文字エンコーディングを指定できるようになっています。

PostgreSQLサーバのインストールや設定によっては、デフォルトの文字エンコーディングがUTF-8になっていない場合があります。

デフォルト文字エンコーディングがUTF-8の場合
[framework@localhost www]$ psql -U postgres -l
          List of databases
   Name    |     Owner     | Encoding
-----------+---------------+----------
 guestbook | postgres      | UTF8
 mediawiki | mediawikiuser | UTF8
 postgres  | postgres      | UTF8
 template0 | postgres      | UTF8
 template1 | postgres      | UTF8
(5 rows)
デフォルト文字エンコーディングがSQL_ASCIIの場合
[yohgaki@dev $ psql -U postgres -l
Password for user postgres:
           List of databases
   Name    |     Owner     | Encoding
-----------+---------------+-----------
 guestbook | postgres      | SQL_ASCII
 mediawiki | mediawikiuser | SQL_ASCII
 postgres  | postgres      | SQL_ASCII
 template0 | postgres      | SQL_ASCII
 template1 | postgres      | SQL_ASCII
(5 rows)

PostgreSQLのSQL_ASCIIエンコーディングは、バイナリエンコーディングと呼んでもよいエンコーディングです。文字エンコーディングが正しいかのチェックは行われません。どのような文字エンコーディングであっても保存できますが、不正な文字エンコーディングの文字列はセキュリティ上の問題となる場合があります。このため、特別な理由が無い限りSQL_ASCIIは使用してはならない文字エンコーディングです。これは、PostgreSQL以外のデータベースサーバを利用している場合でも同じです[1]⁠。

もし、利用されているデータベースの文字エンコーディングがUTF8以外の場合、データベースを再作成する必要があります。

ユーザアカウントの問題

データベースユーザが特権ユーザである「postgres」アカウントを利用しています。特権ユーザを通常のデータベースアクセスに利用することは好ましくありません。一般ユーザを作ってアクセスすべきです。特権ユーザを利用している場合、アクセス制限が機能しません。使用中のデータベース以外のデータベースでも簡単に破壊できてしまいます。

通常、アプリケーションごとに別のユーザを作成すべきです。

データベースの問題を修正

PostgreSQLの場合、文字エンコーディングを変更するにはデータベースを再作成する必要があります。データを保存したい場合、データベースのバックアップを取得する必要があります。PostgreSQLの場合は、pg_dumpコマンドを使用してバックアップを取得できます。今回はサンプル用のデータベースなので単にデータベースを削除します。

データベースの削除
$ dropdb guestbook

ゲストブックデータベースにアクセスする新しいデータベースユーザを作ります。

$ /opt/PostgreSQL/8.3/bin/createuser -U postgres -W zfguestbook
Shall the new role be a superuser? (y/n) n
Shall the new role be allowed to create databases? (y/n) n
Shall the new role be allowed to create more new roles? (y/n) n
Password:zfguestbook(※実際には表示されません)

通常ユーザ、データベース作成権限なし、データベースロール(ユーザ)作成権限なしで作成します。通常、ユーザ名と同じパスワードは使用すべきではありませんが、便宜上ユーザ名と同じパスワードを設定しました。

文字エンコーディングとオーナー名を指定してデータベースを作成
$ createdb -U postgres -O zfguestbook -E utf8 guestbook
作成したデータベースを確認
[yohgaki@dev PHP-ZendFramework]$ psql -l -U postgres
Password for user postgres:
           List of databases
   Name    |     Owner     | Encoding
-----------+---------------+-----------
 guestbook | zfguestbook   | UTF8
 mediawiki | mediawikiuser | SQL_ASCII
 postgres  | postgres      | SQL_ASCII
 template0 | postgres      | SQL_ASCII
 template1 | postgres      | SQL_ASCII
(5 rows)

psqlコマンドでデータベースを一覧すると新しいオーナーとエンコーディングでデータベースが作成されていることが確認できます。

テーブルの作成
$ psql -U zfguestbook -f /www/guestbook/guestbook.sql guestbook
作成したテーブルの確認
$ psql -U zfguestbook guestbook
Password for user zfguestbook:
Welcome to psql 8.3.5, the PostgreSQL interactive terminal.

Type:  \copyright for distribution terms
       \h for help with SQL commands
       \? for help with psql commands
       \g or terminate with semicolon to execute query
       \q to quit

guestbook=> \d
                 List of relations
 Schema |       Name       |   Type   |    Owner
--------+------------------+----------+-------------
 public | guestbook        | table    | zfguestbook
 public | guestbook_id_seq | sequence | zfguestbook
(2 rows)

アプリケーションの修正

アプリケーションも修正しなければなりません[2]⁠。まず、ベタなguestbookの問題を修正してから、ZendFramework版の問題を修正します。

今回、修正したものはguesetbook2として別ディレクトリに保存して作業することにします。

通常版guestbookの修正

問題となる部分は

  • 文字エンコーディングの処理
  • 文字列のエスケープ処理

です。

前回作ったゲストブックアプリケーションでは、日本語を入力すると文字化けしてしまいます。文字エンコーディングの処理とエスケープ処理に問題があったことが原因です。

環境によってはページの表示さえ文字化けしてしまいます。

図1 ページ表示の文字化け
図1 ページ表示の文字化け

この環境ではWebサーバがUTF-8エンコーディングのページをShift_JISエンコーディングのページとして送信しているため、文字化けしています。文字化けしなかった場合はサーバのデフォルトエンコーディングがUTF-8に設定してあったか、ブラウザが文字エンコーディングの自動判別を行ったためです。

図2 ポスト完了後のページの文字化け
図2 ポスト完了後のページの文字化け

ブラウザの文字エンコーディングの自動認識を有効にしてUTF-8で記述されたページであることを認識させるだけでは不十分です[3]⁠。

図3 自動認識を有効にしてpostした場合
図3 自動認識を有効にしてpostした場合

文字エンコーディングの自動認識を有効にしても、サーバ側のPHPスクリプトが文字エンコーディングを正しく処理していないので文字化けを起こしています。

図4 リスト表示の文字化け
図4 リスト表示の文字化け

このような文字化けが発生する原因は、Webサーバ側のプログラムが正しい文字エンコーディングを明示的に指定していないことによります。

PHPで日本語アプリケーションを作る場合、必ず設定すべき文字エンコーディング関連の項目は、PHPのデフォルト文字エンコーディング設定とmbstringモジュールの内部エンコーディング設定です。htmlentities/htmlspecialchars関数の動作はこれらの設定に影響されています。つまり、正しくHTMLエスケープするためには、文字エンコーディング設定が欠かせないことを意味しています[4]⁠。

// 文字エンコーディングを設定する
ini_set('default_charset', 'UTF-8');
mb_internal_encoding('UTF-8');

これらの設定はphp.iniからも行えますが、一般にソースを公開するスクリプトであれば記述しておくべき設定です。ほかにも必ず設定すべきphp.ini項目がありますが、これらは順次紹介していきます。

これだけでかなり動作がマシになりますが、エスケープ処理を行っている箇所、つまりhtmlentities関数の呼び出し方も変えておいたほうがよいです。

日時: <?php echo htmlentities($row['date_created'], ENT_QUOTES, 'UTF-8') ?><br />

デフォルトではENT_COMPATモードでエスケープ処理され、シングルクオートはエスケープされません。このため、シングルクオートで囲まれたJavaScriptの文字列、HTMLの属性値などを安全にブラウザに渡すためには、ENT_QUOTESモードでシングルクオートもエスケープするようにしないとJavaSscriptインジェクション等が可能なってしまいます。

これらの修正を行った通常版ゲストブックアプリケーションは、今度は正しく動作するようになります。

図5 日本語が正しく表示されている状態
図5 日本語が正しく表示されている状態

ドキュメント以外のスクリプト

guestbook2ではinit.phpがドキュメントルートの下に配置されています。本来はドキュメントルート以下にはアクセスする必要が無いファイルを配置してはなりません。しかし、多くのPHPアプリケーションはインストールを簡単にするため、直接アクセスする必要がないライブラリや初期化ファイルをドキュメントルート以下に配置しています。

ドキュメントルートにアクセスする必要がないファイルは配置しないようにすることが重要です。もし、配置する場合は少なくとも直接アクセスできないような処理が必要です。

<?php
if (basename($_SERVER['SCRIPT_FILENAME']) == basename(__FILE__)) {
       trigger_error(__FILE__.' is called directly by '.$_SERVER['REMOTE_ADDR']);
       die('Cannot call directly');
}
図6 アクセスエラー
図6 アクセスエラー

パス情報は攻撃者が攻撃を成功させるための重要な情報となります。この画面ではエラーメッセージが表示されていますが、実際に運用する場合はエラーメッセージが表示されないようにしなければなりません。

ZendFramework版guestbookの修正

ZendFramework版guestbookも通常版と同様の変更を行う必要がありますが、実装方法は多少異なります。

解説を進める前に、文字エンコーディングが統一されていないと発生するエラーを紹介します。先ほどの文字化けした環境から「日本語」を送信すると、データベースサーバが正しくUTF-8エンコーディングを使用していれば次の図のようなエラーが発生します。

図7 データベースサーバ側の文字エンコーディングチェックによるエラー
図7 データベースサーバ側の文字エンコーディングチェックによるエラー

これはUTF-8エンコーディングのデータベースに対して、Shift_JISの文字列を挿入しようとしたせいで、PostgreSQLサーバがエラーを発生させたために起きたエラーです。

実はWebアプリケーションもPostgreSQLサーバと同様に入力文字エンコーディングが正しいかチェックしなければなりません。不正な文字エンコーディングがさまざまな攻撃に利用可能だからです。本来このチェックは必要ですが、今回の修正では見送っています。ここでは「すべてのアプリケーションは文字エンコーディングが正しいかチェックしなければならない」とだけ覚えておいてください。

ZendFrameworkのViewオブジェクトには文字エンコーディング設定がありますが、これはphp.ini設定に影響しません。正しくHTTPヘッダで文字エンコーディングを指定するには、通常版と同様にini_set/mb_internal_encoding関数を利用して文字エンコーディングを設定しなけばなりません。

コントローラのinitメソッドに追加するコード
$this->view->setEncoding('UTF-8');
$this->view->setEscape('htmlentities');
ini_set('default_charset','UTF-8');
mb_internal_encoding('UTF-8');

前のバージョンのguestbookではZendFrameworkアプリケーションらしく、htmlentities関数を直接呼び出していました。しかしZend Viewはエスケープ用にescapeメソッドを持っています。

ビュースクリプトでのエスケープ
件名: <?php echo $this->escape($_POST['title']) ?><br />

このescapeメソッドを利用すればよいのですが、多少注意が必要です。escapeメソッドはZend Viewの抽象クラスで次のように定義されています。

Zend/View/Abstract.php
    /**
     * Escapes a value for output in a view script.
     *
     * If escaping mechanism is one of htmlspecialchars or htmlentities, uses
     * {@link $_encoding} setting.
     *
     * @param mixed $var The output to escape.
     * @return mixed The escaped value.
     */
    public function escape($var)
    {
        if (in_array($this->_escape, array('htmlspecialchars', 'htmlentities'))) {
            return call_user_func($this->_escape, $var, ENT_COMPAT, $this->_encoding);
        }

        return call_user_func($this->_escape, $var);
    }

$this->_escapeのデフォルトは⁠htmlspecialchars⁠に設定されています。筆者は、万が一出力するデータに異常があった場合のセキュリティリスクを低下させることを理由に、htmlspecialchars関数よりhtmlentities関数の利用をお勧めしています。htmlentities関数によるエスケープ処理を行うために、$this->setEscape('htmlentities');を指定しています。

ここで気づいた方も居ると思います。Zend Frameworkのエスケープメソッドでは、ENT_QUOTESモードが利用されていません。既に説明した通り、JavaScriptの文字列やHTMLの属性値のクオート方法によっては、このままではセキュリティ上の問題となります。

フレームワークはベストプラクティスを集めている、と言われていますが、実際にはセキュリティ上のベストプラクティスが実装されているとは限りません。エスケープ方法はデフォルトでできるかぎり安全性の高い方法でエスケープすべきですが、そのようになっていません。実はこのような動作をしているフレームワークはZend Frameworkだけではありません。マニュアルや解説書などには記載されていませんが、同じような仕様のエスケープ方法となっているフレームワークは多く存在します。

エスケープモードは設定できるようになっていませんが、エスケープ関数は独自の関数が利用可能です。ここでは、独自関数を利用して任意のエスケープ方法が設定できることだけ覚えておいてください。

パフォーマンスの比較

Webアプリケーションフレームワークを利用するメリットは開発の効率化とメンテナンス性の向上です。以降では通常版のPHPアプリケーションを作成しないので、同じ機能を持ったスクリプトでどの程度のパフォーマンスの差異があるか簡単なベンチマークをとってみます。

データベースを利用したアプリケーションではデータベースがパフォーマンスのボトルネックになります。少しでもデータベースの影響を少なくするために、PostgreSQLの設定は多少チューニングします。

postgresql.conf
shared_buffers = 512MB
fsync = off

上記の設定変更を行った後、データベースを再起動します。

ベンチマークにはabコマンドを使用します。クライアントからはzendframeworkをホスト名としてサーバにアクセスできるようにサーバとクライアントを設定しています。

コマンド
ab -c 50 -n 10000 http://zendframework/guestbook/list/
ab -c 50 -n 10000 http://zendframework/list/
サーバ

Core2Quad Q6600/8GB RAM/SATA-II 1TB

クライアント

Core2Duo 2.1Ghz/4GB RAM

サーバとクライアントは1Gbpsのイーサネットで接続されています。

通常版
Requests per second:    471.69 [#/sec] (mean)
ZendFramework版
Requests per second:    207.58 [#/sec] (mean)

当然ですが通常版のほうがシンプルであるため倍以上高速です。データベースを利用していないアプリケーションであれば、違いはさらに顕著になります。

例えば、このベンチマークシステムの場合、⁠Hello World⁠プログラムを作成して比較すると10倍以上の違いになります。

通常版
Requests per second:    7014.77 [#/sec] (mean)
ZendFramework版
Requests per second:    587.34 [#/sec] (mean)

実行しているコード数がまったく異なるので10倍程度の違いは当然です。パフォーマンス的にはかなりのハンデのように思えるかも知れませんが、実際のアプリケーション構築ではリバースプロキシを活用するなどの手法で多くのパフォーマンス上の問題は解決できます。

guestbookとguestbook2の差分

UNIX系のシステムではテキストファイルの差分を抽出するdiffと呼ばれる便利なツールがあります。Windows用のdiffツールも数多く作られています。ソースコード管理システムのCVS, Subversion, Mercurial, Git等もdiff形式の差分ファイルをサポートしています。この連載ではMercurialを利用する予定ですが、ここではdiff形式の差分ファイルを紹介します。

diffコマンド

UNIX/Mac OS Xではdiffコマンドが利用できるようになっているはずです。コマンドには方言があるので詳しい動作やオプションはシステムのマニュアルを参照してください。

使用法
diff [オプション]... FILES
$ diff -u -r -b --new-file /www/guestbook /www/guestbook2 | less

等とすると差分が参照できます。

各オプションの意味
オプション動作
-uユニバーサル形式の差分ファイルを生成
-r再帰的にファイルを比較
-b空白文字の違いを無視
--new--file新しいファイルも差分として表示
差分の一部
diff -ur -b --new-file /www/guestbook/application/views/scripts/list/list.phtml /www/guestbook2/application/views/scripts/list/list.phtml
--- /www/guestbook/application/views/scripts/list/list.phtml    2009-04-15 05:38:04.000000000 +0900
+++ /www/guestbook2/application/views/scripts/list/list.phtml   2009-04-18 05:42:36.000000000 +0900
@@ -10,9 +10,9 @@
 <table>
      <?php foreach ($this->rows as $row): ?>
      <tr><td>
-    日時: <?php echo htmlentities($row['date_created']) ?><br />
-     件名:<?php echo htmlentities($row['title']) ?> <br />
-     メッセージ: <?php echo htmlentities($row['content']) ?><br />
+    日時: <?php echo $this->escape($row['date_created']) ?><br />
+     件名:<?php echo $this->escape($row['title']) ?> <br />
+     メッセージ: <?php echo $this->escape($row['content']) ?><br />
      </td>
      </tr>
      <?php endforeach; ?>

差分ファイルを初めて見る方でも、意味が直感的に理解できると思います。最初の行がコマンド、次に比較しているファイル、差分の位置情報、そして差分が⁠-⁠⁠+⁠で始まる行で分かるようになっています。⁠-⁠で始まる行は削除された行、⁠+⁠で始まる行が追加された行です。

この差分情報でどのような修正が行われたか分かります。diffコマンドで抽出した差分情報はpatchコマンドで追加したり、削除したできます。

guestbookとguestbook2の差分は以下からダウンロードできます。

guestbookとguestbook2の差分
guestbook2.diff
guestbook2アプリ
guestbook2-20090430.tar.gz

まとめ

今回は前回作った素朴なゲストブックアプリのセキュリティ上の問題等を修正しました。

  • 文字エンコーディングは明示的に指定し、アプリケーション内で統一する
  • エスケープ方法は可能なかぎり安全性が最も高い方法を利用する

Zend Viewのescapeメソッドの実装も紹介しました。Zend Frameworkはオブジェクト指向設計を採用しているので、基本的な使い方はAbstract(抽象)クラスやInterface定義を参照すれば分かるようになっています。

おすすめ記事

記事・ニュース一覧