k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

Smarty3のHTMLエスケープ方法いろいろ + 修飾子プラグインと変数フィルタ (PHP Advent Calendar 2012 Day 11)

PHP Advent Calendar 2012 11日目の記事です。

昨日は Shusuke Otomo さんの 初めて Pull Request した話。 #phpadvent2012 | slumbers でした。


皆さん、テンプレートエンジンは何を使ってますか?
おおっぴらに使ってますと発言するのもちょっと勇気がいるくらい、スキル高そうなPHPerの皆さんからよくdisられている(ような気がする)Smartyですが、実際のところ現役のユーザーは結構いるんじゃないでしょうか。
(参考記事 PHP ユーザは実際はどんなテンプレートエンジンを使っているのか? (途中経過) - A Day in Serenity @ kenjis

今回はそんなSmartyを題材に「テンプレート変数のHTMLエスケープ」と、それに関連する機能「修飾子プラグイン (Modifier Plugin)」「変数フィルタ (Variable Filter)」について改めて調べてみました。
使用したバージョンは Composer でインストールした dev-trunk = Smarty-3.1.12 ベースの開発版(2012年11月27日更新)です。

自動でHTMLエスケープする方法

Smarty3ではテンプレート変数の展開時に何らかの処理を追加する方法が複数あります。

  • 変数の修飾子 (Modifier)
  • 変数フィルタ (Variable Filter)

自動で(暗黙的に)行うHTMLエスケープについては、この2つの組み込みプラグインを使う方法に加えて、バージョン3.1から追加された escape_html プロパティというのもあって、複雑な状況になっています。
(併用した際の実行順序や無効化する方法については、後述します)


デフォルト修飾子 (default_modifiers) プロパティに組み込みの escape 修飾子を指定する

default_modifiers は Smarty2 の頃からサポートされていた、テンプレート変数の展開時に自動的に実行したい修飾子を指定する機能です。
この配列型のプロパティで、組込みの変数エスケープ用修飾子である escape に "html" オプションをつけて指定します。

<?php
$smarty->default_modifiers = array('escape:"html"');

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/1

escape_html プロパティを true に設定する

escape_html はバージョン3.1から追加されたBoolean型プロパティです。
このプロパティに true を設定しておくことで、全てのテンプレート変数出力に対してHTMLエスケープが施されます。

<?php
$smarty->escape_html = true;

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/2

組み込みの変数フィルタ htmlspecialchars を有効にする

変数フィルタはバージョン3から追加された機能で、デフォルト修飾子と同じく、全てのテンプレート変数出力に対して自動的に出力加工を行うことができます。
組み込みの変数フィルタとして htmlspecialchars が用意されており、これを loadFilter() メソッドで有効にします。

<?php
$smarty->loadFilter('variable', 'htmlspecialchars');

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/3

なお詳しくは後述しますが、変数フィルタは修飾子の次に実行されます。
changelogを見たところ、おそらく Smarty3.1 からこの仕様に固まったようです。


Smarty3の組み込み機能によるHTMLエスケープのまとめ

HTMLエスケープを行う複数の方法について、組み込みの機能である $escape_html プロパティ、htmlspecialchars 変数フィルタ、escape 修飾子のそれぞれの動作と、それらを併用した場合の処理順序を調査しました。

$escape_html プロパティと組み込みの htmlspecialchars 変数フィルタ、escape 修飾子の動作

$escape_html プロパティを有効にした場合、全てのテンプレート変数が htmlspecialchars(変数値, ENT_QUOTES, SMARTY_RESOURCE_CHAR_SET) で処理されます。
SMARTY_RESOURCE_CHAR_SET はテンプレートのエンコーディングを設定するための定数で、あらかじめ定義していない場合はSmartyによって自動的に、mbstring拡張があれば UTF-8 が、なければ ISO-8859-1 が設定されます。
この定数値は、Smartyクラスの静的プロパティ $_CHARSET にも自動的に設定されるのですが、更にmbstring拡張が有効な場合、Smartyクラスのコンストラクタにおいて、mbstring.internal_encoding(mbstringのデフォルトエンコーディング)として設定されます。

そして、組み込み変数フィルタの htmlspecialchars では htmlspecialchars(変数値, ENT_QUOTES, Smarty::$_CHARSET) のように $_CHARSET プロパティの値が利用されます。
また、組み込み修飾子の escape:"html" においても、関数の第3引数(修飾子の第2引数)$char_set が未指定の場合にこの $_CHARSET プロパティの値が利用されます。

Smarty組み込みの機能において htmlspecialchars()関数の実行時にエンコーディングが明示されていない場合は、常に SMARTY_RESOURCE_CHAR_SET 定数値に基づいたエンコーディングが利用されるというわけです。

SMARTY_RESOURCE_CHAR_SET 定数によるユーザー定義の手段と後方互換性を維持しつつ、セキュリティを考慮した結果、このような実装になったものと思います。

ただ、Smartyクラスのインスタンス生成時に mbstring.internal_encoding が強制的に設定される点は、要注意でしょうか。
(試してはいませんが、mbstring.internal_encoding と異なる値を SMARTY_RESOURCE_CHAR_SET に指定した場合、Smartyクラスのインスタンス生成後は、元の設定値を前提として書かれたコードが正常に動かなくなるような…)


変数の修飾子および変数フィルタと、$escape_html プロパティの実行順序

変数の修飾子およびフィルタは、以下の順で実行されます。

  1. テンプレートで指定された修飾子
  2. default_modifiers で指定されたデフォルト修飾子 (複数の場合は指定順に全て実行)
  3. $escape_html プロパティによるHTMLエスケープ処理 (TRUEが設定されている場合のみ。デフォルトはFALSE)
  4. 登録された変数フィルタ (複数の場合は登録順に全て実行)
  5. Smartyオートローダによって読み込まれた変数フィルタ (未確認)
  6. テンプレートインスタンスの変数フィルタ? (未確認)

公式ドキュメントには以下のように説明されています。

Modifiers and Filters are run in the following order: modifier, default_modifier, $escape_html, registered variable filters, autoloaded variable filters, template instance's variable filters. Everything except the individual modifier can be disabled with the nofilter flag.

http://www.smarty.net/docs/en/variable.escape.html.tpl

default_modifiers が廃止という情報がWeb上で散見されますが、そうではなく、$escape_html プロパティと変数フィルタという別の機能が追加されたのです。
(自動で実行する手段としては機能的に重複しているのは確かですし、将来的にはどうなるか分かりませんが…)

デフォルト修飾子と変数フィルタを無効にする nofilter フラグ

デフォルト修飾子を無効にする方法としてSmarty2で提供されていた {$variable|smarty:nodefaults} 廃止され、Smarty3では新たに {$variable nofilter} が追加されています。
テンプレート変数に nofilter フラグが指定されている場合、$escape_html プロパティによるエスケープ処理および、デフォルト修飾子、変数フィルタの処理は実行されません。
修飾子と nofilter フラグが併記されている場合については、デフォルト修飾子は実行されませんが、テンプレート変数に明示された修飾子は実行されます。

デフォルト修飾子、 $escape_html プロパティ、変数フィルタの3箇所でHTMLエスケープを指定した場合

以下のように、全てのHTMLエスケープ機能を同時に利用した場合はどうなるのか、調査してみました。

利用側コード(一部抜粋)

<?php
$page = new \StdClass();
$page->content = '<script>alert("Hello X\'SS!")</script>';

$smarty->escape_html = true;
$smarty->loadFilter('variable', 'htmlspecialchars');
$smarty->default_modifiers = array('escape:"html"');

$smarty->assign('this', $page);
$smarty->display('index.html');

テンプレート(一部抜粋)

<pre>{$this->content}</pre>
<pre>{$this->content nofilter}</pre>
<pre>{$this->content|escape:"html" nofilter}</pre>

{$this->content}

  1. デフォルト指定されたビルトイン修飾子の escape:"html" によりエスケープされる
  2. escape_html プロパティによってエスケープされる
  3. 組み込みの htmlspecialchars 変数フィルタによってエスケープされる
&amp;lt;script&amp;gt;alert(&amp;quot;Hello X&amp;#039;SS!&amp;quot;)&amp;lt;/script&amp;gt;

結果はこのように、元のデータが三重にエスケープされて出力されます。

{$this->content nofilter}

  1. nofilter フラグにより全てのデフォルト修飾子および escape_html プロパティ、変数フィルタが無効となる

全てのエスケープが無効となるため、JavaScriptが実行されてしまいます。

{$this->content|escape:"html" nofilter}

  1. nofilter フラグにより全てのデフォルト修飾子および escape_html プロパティ、変数フィルタが無効となる
  2. 明示された escape 修飾子によりエスケープされる
<script>alert("Hello X'SS!")</script>

このように、普通にエスケープされます。

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/4 ※Alert注意

結局どの方法でHTMLエスケープすればいい?

私の場合は以下のように判断しました。

  • 自動でHTMLエスケープを行いたい場合は escape_html プロパティで一括して行うべき。(そのためだけに用意されている機能だから)
  • 自動で独自のエスケープ処理を行いたい場合は修飾子プラグインを登録してデフォルト修飾子に指定するか、変数フィルタに登録する。

自動HTMLエスケープはあなたにとって本当に必要?

Web上の議論を眺めると、セキュリティ上の理由から、自動で変数がHTMLエスケープされないテンプレートシステムは問題だ、といった論調が強いように感じます。
しかし私個人は、今までテンプレート変数の自動HTMLエスケープに関する機能は使ったことがありません。
過去にCMS系システムの開発に携わることが多かったのですが、自動HTMLエスケープを行ってしまうと、以下のような状況に頻繁に遭遇することになります。

  • データをHTMLの要素として出力する場合、自動エスケープを無効にするための余計な記述が必要
  • nl2br()のようなエスケープ後の変換処理を行う場合、自動エスケープ無効の記述に加えて、本来のエスケープ処理の記述も必要
  • 自動エスケープに関わらず、JavaScriptやURL内に出力する場合など、エスケープ処理の使い分けは必要

このように、自動エスケープが不要なケースで余計な手間がかかってしまう上に、結局テンプレートを書く人がコンテキストによるエスケープの違いを意識して使い分ける必要がある限り、未然に事故を防ぐ効果は無いと思うのです。

自動エスケープを前提にテンプレートを作成してから、後で方針変更することになると、その対応作業は大変な労力になります。
当然のことですし、偉そうなことを言うようで恐縮ですが、自分と共に開発するわけではない他人の意見に盲目的に従うのではなく、現場でよく考えて決めるべきではないでしょうか。

(試しに検索してみたら、こういうテンプレートエンジンもあるようです→第36回 MOPS:コンテクストを検出するHTMLエスケープ:なぜPHPアプリにセキュリティホールが多いのか?|gihyo.jp … 技術評論社

余談:HTML_Template_Flexyの場合

過去に何度か利用したテンプレートエンジンに、PEARHTML_Template_Flexy があります。
テンプレートファイルを解析してHTML要素をElementオブジェクトに変換し、そのオブジェクトに対して属性や内容を操作する仕組みが特徴ですが、当時からSmartyと比べて余計な機能がないためフレームワークとの役割分担に無駄がなく、内部構造も綺麗だったので、個人的にかなり気に入って使っていました。
テンプレートの制御構文も "flexy:if" や "flexy:foreach" といったHTMLの属性として記述、テンプレートとして動作させる場合のみ要素を非表示にする "flexy:ignore" といった属性も用意されており、ピュアHTMLではないものの、デザイナーとの協業はしやすいテンプレートエンジンだと思います。

修飾子の指定がなければ自動で htmlspecialchars() によるHTMLエスケープを行う仕様ですが、組込みの修飾子として以下のようなものが用意されています。

  • {variable:h} HTMLエスケープしない
  • {variable:u} HTMLエスケープせず、URLエンコードする
  • {variable:n} HTMLエスケープせず、number_format()する
  • {variable:b} HTMLエスケープ後、nl2br()する
  • {variable:e} HTMLエンティティに変換する

今見ても、この修飾子のHTMLエスケープ仕様はなかなか実用性に優れていると思います。
(htmlspecialchars()の第2引数と第3引数が指定されていない点は問題視されそうですが)



閑話休題

修飾子プラグインと変数フィルタにユーザー定義のコールバック関数を利用する

修飾子プラグインと変数フィルタのいずれにおいても、ユーザーが独自のコールバック関数を登録する手段が提供されています。
せっかくなので、組み込みのエスケープ処理では不都合な場合に使える、これらの機能について紹介します。

修飾子プラグインにユーザー定義のコールバック関数を登録する

修飾子プラグインは組み込みの修飾子以外にも、registerPlugin() メソッドにより、ユーザー定義のコールバック関数を登録できます。
また、前述したデフォルト修飾子に指定することで、登録した修飾子プラグインを変数の展開時に自動的に実行することもできます。

<?php
class Util
{
    public static function escapeHtml($value) {
        return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
    }
}

$smarty->registerPlugin('modifier', 'escape_html', array('Util', 'escapeHtml'));
$smarty->default_modifiers = array('escape_html');

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/5

ただし残念ながら、現状では全てのcallableが利用できる訳ではありません。

修飾子プラグインに無名関数が登録できない問題

このように、無名関数を修飾子プラグインとして登録してデフォルト修飾子に指定し、実行しようとすると…

<?php
$smarty->registerPlugin('modifier', 'escape_html', function($value) {
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
});
$smarty->default_modifiers = array('escape_html');

libs/sysplugins/smarty_internal_compile_private_modifier.php にて 'Object of class Closure could not be converted to string' の Catchable fatal error (E_RECOVERABLE_ERROR) が発生してしまいます。

変数フィルタにユーザー定義のコールバック関数を登録する

組み込みの変数フィルタ以外にも、registerFilter() メソッドにより、ユーザー定義のコールバック関数を登録できます。

<?php
class Util
{
    public static function escapeHtml($value) {
        return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
    }
}

$smarty->registerFilter('variable', array('Util', 'escapeHtml'));

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/6

ただし残念ながら、こちらも現状では全てのcallableが利用できる訳ではありません。

変数フィルタにインスタンスメソッドのコールバック配列が登録できない問題

このように、インスタンスメソッドのコールバック配列を変数フィルタに登録して実行しようとすると…

<?php
class StringConverter {
    public $encoding;
    public function __construct() {
        $this->encoding = mb_internal_encoding();
    }
    public function escapeHtml($value) {
        return htmlspecialchars($value, ENT_QUOTES, $this->encoding);
    }
}

$converter = new StringConverter();

$smarty->registerFilter('variable', array($converter, 'escapeHtml'));

コンパイルしたテンプレートの実行時に Use of undefined constant StringConverter_escape - assumed 'StringConverter_escape' の Notice (E_NOTICE) が発生してしまいます。

変数フィルタに無名関数が登録できない問題

このように、無名関数を変数フィルタに登録して実行しようとすると…

<?php
$smarty->registerFilter('variable', function($value) {
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
});

libs/sysplugins/smarty_internal_templatebase.php にて 'Illegal offset type' の Warning (E_WARNING) が発生してしまいます。


SmartyFunctionRegistry というものを作ってみた

前述のように、修飾子プラグインと変数フィルタでユーザー定義関数を利用する際、現状では受け入れる関数の型がそれぞれ微妙に異なっており、無名関数についてはいずれも対応していません。
この問題を解決するために、SmartyFunctionRegistry というものを作成しました。

SmartyFunctionRegistry に登録したコールバック関数を修飾子プラグインに利用する

このように、SmartyFunctionRegistry::set() で登録したコールバックは、SmartyFunctionRegistry::plugin() でSmartyのプラグイン形式に変換して取り出せます。

<?php
class StringConverter {
    public $encoding;
    public function __construct() {
        $this->encoding = mb_internal_encoding();
    }
    public function escapeHtml($value) {
        return htmlspecialchars($value, ENT_QUOTES, $this->encoding);
    }
}

$converter = new StringConverter();

$registry = new \Holy\SmartyFunctionRegistry();

$registry->set('escape_html', array($converter, 'escapeHtml'));

$smarty->registerPlugin('modifier', 'escape_html', $registry->plugin('escape_html'));

同様に、無名関数も利用できます。

<?php
$registry->set('marquee', function($value) {
    return sprintf('<marquee>%s</marquee>', $value);
});

$smarty->registerPlugin('modifier', 'marquee', $registry->plugin('marquee'));

$smarty->default_modifiers = array('escape_html', 'marquee');

複数のデフォルト修飾子を指定した場合は、指定した順に全て実行されます。
(上記の例の場合、HTMLエスケープされた内容がmarquee要素として出力されます)

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/7

SmartyFunctionRegistry に登録したコールバック関数を変数フィルタに利用する

このように、SmartyFunctionRegistry::set() で登録したコールバックを、修飾子プラグインの場合と同様に SmartyFunctionRegistry::filter() でSmartyのフィルタ形式に変換して取り出せます。

<?php
class StringConverter {
    public $encoding;
    public function __construct() {
        $this->encoding = mb_internal_encoding();
    }
    public function escapeHtml($value) {
        return htmlspecialchars($value, ENT_QUOTES, $this->encoding);
    }
}

$converter = new StringConverter();

$registry = new \Holy\SmartyFunctionRegistry();

$registry->set('escape_html', array($converter, 'escapeHtml'));

$smarty->registerFilter('variable', $registry->filter('escape_html'));

もちろん、無名関数も利用できます。

<?php
$registry->set('marquee', function($value) {
    return sprintf('<marquee>%s</marquee>', $value);
});

$smarty->registerFilter('variable', $registry->filter('marquee'));

複数の変数フィルタを登録した場合は、登録した順で全て実行されます。
(上記の例の場合、HTMLエスケープされた内容がmarquee要素として出力されます)

動作サンプル http://kholy.gehirn.ne.jp/php-advent/2012/8

サンプルコードにはありませんが、__invoke() マジックメソッドを実装したオブジェクトに対しても、同様に動作します。

なお、変数フィルタで実行させるコールバック関数の第1引数にはテンプレート変数の値が渡されますが、第2引数には Smarty_Internal_Template オブジェクトが渡されます。
そのため、修飾子とは違って標準の関数名をそのままコールバックに設定することは事実上不可能ですし、第2引数以降のオプション引数を指定する手段もありません。
修飾子プラグインと変数フィルタを同じ関数で併用する場合は、要注意です。

コールバックを代理実行する方法

SmartyFunctionRegistry では、__call() と __callStatic() の2つのマジックメソッドを使うことで、コールバックの代理実行を実現しています。

当然ながら、マジックメソッド経由でコールバックが呼ばれた場合、通常のコールバック実行よりも速度は遅くなります。
そのため、登録されたコールバックをSmartyのフィルタやプラグイン形式に変換して返す filter(), plugin() メソッドでは、問題なくSmartyに登録できる形式のコールバック関数については、そのまま返しています。

詳しい解説は省略しますが、コードは以下の通りです。

なお、現状の仕様だと SmartyFunctionRegistry への登録時に指定した名前が、そのままマジックメソッド経由で呼ばれるメソッド名になりますので、同名で異なる種別のフィルタやプラグインを扱えません。
実用するには、コールバック関数の種別によってメソッド名を切り替えるか、マジックメソッドで呼ばれたメソッド名によって返すコールバックを切り替える処理が必要になるかと思います。

Smarty_Internal_Template クラスについて

変数フィルタの第2引数に渡される Smarty_Internal_Template クラスの継承関係は、以下のようになっています。

Smarty_Internal_Data ← Smarty_Internal_TemplateBase ← Smarty_Internal_Template

Smarty_Internal_Data クラスはプロパティに Smarty クラスのインスタンスを保持していて、Smartyテンプレート変数やテンプレートグローバル変数Smarty設定値へのアクセスメソッドが実装されています。
また、それを継承する Smarty_Internal_TemplateBase クラスには fetch() や display() といった出力系のメソッドの他、プラグインやフィルタへのアクセスメソッドや registerObject(), getRegisteredObject() なども実装されています。
Smarty クラス自身も Smarty_Internal_TemplateBase を継承しているので、参照関係がやばいことになってるはず…)

つまり、Smarty_Internal_Template オブジェクトを取得できる関数では、Smartyのあらゆる機能を利用できるということです。
修飾子(modifier)プラグイン関数から利用できるのはテンプレート変数の値のみですが、テンプレート関数(function)、ブロック関数(block)、コンパイラ関数(compiler)についてはフィルタと同様に Smarty_Internal_Template や Smartyインスタンスが引数に渡されますので、まあ何でもやりたい放題ですね…。

このようなSmarty側の制約がありますので、他のテンプレートエンジンでも利用するような処理については、そのままプラグインの仕様に合わせて実装するのではなく、橋渡し役となるクラスを用意した方が良いように感じます。
(今回、修飾子プラグインと変数フィルタにおけるコールバック関数の型の制約を解決するために SmartyFunctionRegistry というものを用意したのも、そのような理由からです)

最後に

以上、長くなりましたが、Smarty3の組み込みのHTMLエスケープ方法と、修飾子プラグイン、変数フィルタについて紹介しました。

Smartyユーザーの中には「新しいテンプレートエンジンを使いたいのに、過去のしがらみで仕方なく使っている」という人もいるかもしれません。
しかし理由はどうあれ、使うのであればちゃんと機能を把握した上で使うべきですし、批判の対象にする場合も同様だと思います。

クソな仕様だと言われながらも広く利用されていった結果、それを前提として書かれてしまったコードへの互換性を簡単に捨てるわけにもいかない…。
それでも、少しずつ新しい機能を取り込んでいこうとするSmartyの現状に、PHPが辿ってきた道のりと同じような印象を感じてしまいました。

Smartyに対するネガティブな意見を目にして同じ気持になることもありますが、このまま使い続けるにせよ、乗り換えるにせよ、せめて独自プラグインの数々を負債ではなく資産にできるよう、前向きに取り組みたいと思います。

おまけの動作サンプルと余談 http://kholy.gehirn.ne.jp/php-advent/2012/9
独自の修飾子プラグインの例が2つと、Smarty3のstring:リソースによる文字列のテンプレート、テンプレート継承の例が含まれています。


そういえば昨年の http://atnd.org/events/22781title=PHP Advent Calendar 2011 にも同じ11日目に書かせていただいたんですが、今年の初夏頃にレンタルサーバの契約継続をうっかり忘れてたせいで、URLを消失させてしまいまして…。
このたび原稿のテキストが見つかったため、内容をはてな記法で修正し、当時の日付で公開しておきました。

エラーハンドラと例外ハンドラによるエラー処理 (PHP Advent Calendar jp 2011 Day 11) - k-holyのPHPメモ