読者です 読者をやめる 読者になる 読者になる

ストリームラッパーを使って素の PHP で自動エスケープ

PHP

素の PHP をテンプレートとして使うフレームワークは今でも結構あるようです。

素の PHP をテンプレートに使う、というのがどういうことかというと、簡単な例ですが次のようなものです。

index.php

<?php
$name = filter_input(INPUT_GET, 'name');
include __DIR__ . '/index.html.php';

index.html.php

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello <?= $name ?>
</body>
</html>

php -S localhost:8888 でビルドインウェブサーバを起動して http://localhost:8888/?name=oreore にアクセスすると Hello oreore と表示されることでしょう。

しかし、↑のコードは $name がエスケープされておらずよろしくありません。 http://localhost:8888/?name=%3Cscript%3Ealert%28%27!!!%27%29%3C/script%3E などとアクセスすると !!! と alert されてしまいます。

これを防止するためには、テンプレートで変数を表示するときに htmlspecialchars をかます必要があります。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello <?= htmlspecialchars($name, ENT_QUOTES) ?>
</body>
</html>

さすがに毎度 <?= htmlspecialchars($name, ENT_QUOTES) ?> などと書くのはめんどくさいので、フレームワークによっては別の書き方が提供されています。

例えば・・・

  • Zend Framework<?= $this->escapeHtml($name) ?> とすればエスケープされます
    • あまり楽になっている気がしないです
  • CakePHP<?= h($name) ?> と書けば良いらしいです
    • こういうグローバル関数がたくさん定義されているようです
  • FuelPHP は変数をビューにアサインしたときに自動でエスケープされるようです
    • Security::htmlentities を見た感じオブジェクトをアサインするときに困りそうです

Twig も Smarty も自動的にエスケープする機能を持っており、かつ、FuelPHP のようなオブジェクトをアサインしたときの問題もありません。素の PHP でも同じようなことがやりたいです。。。

.

.

.

やってみました。

index.php

<?php
require __DIR__ . '/../vendor/autoload.php';

use PhpRenderer\Renderer;

$data = [
    'name' => "<script>alert('!')</script>",
    'html' => "<strong>safe string</strong>",
];

echo (new Renderer)->render(__DIR__ . '/index.html.php', $data);

index.html.php

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello <?= $name ?>.
<br>
None Escape <?php echo $html ?>
</body>
</html>
  • <?= $name ?> のようなショートタグを使うと自動的にエスケープされます
  • <?php echo $html ?> のように普通の PHP タグを使うとエスケープされません

↑の例は次のような HTML になります。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello &lt;script&gt;alert('!')&lt;/script&gt;.
<br>
None Escape <strong>safe string</strong></body>
</html>

実装方法

PHP にはストリームラッパーという便利なものがあります。fopen などのファイルシステム関数に http:// とかを渡すことができるあれです。

実は require や include にもストリームラッパーが使えます(いくつかのストリームラッパーは allow_url_include を on にしなければ使えません)。

例えば compress.zlib ストリームラッパーで gzip 圧縮された PHP のコードを include してみます。

gzip.php

<?php
echo "this is gzip\n";

index.php

<?php
include 'compress.zlib://' . __DIR__ . '/gzip.php.gz';
$ gzip gzip.php
$ php index.php
this is gzip

そして、ストリームラッパーは独自のものを新たに登録することもできます。

なので、独自のストリームラッパーを作れば require や include するときに PHP のコードをプリプロセスすることができます。

実装コード

実装したコードは下記にあります。

StreamWrapper.php がストリームラッパーの実装クラスです。php.renderer:// のようなスキームで呼べるように登録します。

rewrite()PHP のコードを書き換えています。 正規表現でざっくり はさすがに怖い気がしたので token_get_all() の字句解析の結果を元に T_OPEN_TAG_WITH_ECHOT_CLOSE_TAG を書き換えています(<?=?> です)。

問題点

opcache

素の PHP ですが opcache は効きません。opcache は file:// と phar:// 以外のストリームラッパーはキャッシュしないようです。

なので、コードの書き換えは都度発生します。

ストリームラッパーを使わずに、書き換えたコードをどこかに保存してそのまま include すれば大丈夫ですが・・・当初の目論見ではストリームラッパーによって変換された後の opcode がキャッシュされるかなーと思ってたので残念です。

parse error

単純に

<?= $name ?>

<?=StreamWrapper::html( $name )?>

と書き換えているだけなので、

<?= $name; ?>

などと書かれると

<?=StreamWrapper::html( $name; )?>

となってパースエラーになります。

その他

普段まったく使わないのですっかり忘れていましたが asp_tags なんてものもありました。 asp_tags を有効にして <%= の形式のときだけ自動エスケープを行うのも良いかもしれません。

さいごに

社内で勉強会したときのスライド。若干ネタ成分あり。