PDOでの数値列の扱いにはワナがいっぱい

PHP Advent Calendar 2013 in Adventarの15日目です。

みなさん、史上空前のSQLのエスケープブームの中、いかがお過ごしでしょうか?

なお、「我が社のプリペアドステートメントは大丈夫なのか?」という疑問をお持ちの方には、以下の記事をお薦めします。

さて、あまりにエスケープが人気なので、プリペアドステートメントにもう少しがんばってもらいたい気がしました。そこで、今日は、以下の徳丸さんの大変に力作な記事に関連した、PDOでのプリペアドステートメントについての記事を書いてみたいと思います。

一応、今でこそPDOは普通に使われていますが、細かい点までみていくと、仕様なのかバグなのか、あるいはこの仕様はちょっとどうかとか、機能が足りないとか、まだ、いろいろ出てくるんですよね。まあ、でもほとんどの場合、問題なく世界は動いているので、あまり神経質になる必要もないのでしょうが。

ということで、この記事もそのような些細な内容です。

徳丸さんが記事で書かれていないこと

記事中で言及されている

  • 2 いきなりはじめるPHP~ワクワク・ドキドキの入門教室~(谷藤賢一著)
  • 3 気づけばプロ並みPHP~ショッピングカート作りにチャレンジ!(谷藤賢一著)
  • 4 基礎からのPHP(西沢 夢路著)
  • 5 かんたんWebプログラミング! これから始める人のPHP学習帖(小川 淳一著)
  • 6 パーフェクトPHP(小川 雄大、柄沢 聡太郎、橋口 誠著)
  • 9 PHP逆引きレシピ 第2版(鈴木 憲治他著)

は、PDOを使っており、「数値列の扱い」は「プレースホルダのため考慮の必要がない」とされています。

これは、SQLインジェクション対策という観点からはその通りであり、何の問題もありません。

ただし、これらの書籍のPDOの解説が本当に正しいかどうかというと、実は、少々疑問があるのです。

ところが、私は、上記の書籍のうち、『パーフェクトPHP』と『PHP逆引きレシピ 第2版』しか持っていないため、それら以外の内容を確認することができません。ということで、ここでは『パーフェクトPHP』のみを取り上げます。

『パーフェクトPHP』のPDOのプリペアドステートメントの解説

P.449に解説があり、以下のコードがあります。

$sql = "SELECT * FROM book WHERE title = ?";
$stmt = $con->prepare($sql);
$stmt->execute(array('パーフェクトPHP'));

このコードには特に問題はありません。ただし、パラメータは数値列ではなく文字列です。

パラメータに数値を使っているところは意外に少なく、P.285に、以下のコードがありました。user_idがINT型です。

    public function insert($user_id, $body)
    {
        $now = new DateTime();

        $sql = "
            INSERT INTO status(user_id, body, created_at)
                VALUES(:user_id, :body, :created_at)
        ";

        $stmt = $this->execute($sql, array(
            ':user_id'    => $user_id,
            ':body'       => $body,
            ':created_at' => $now->format('Y-m-d H:i:s'),
        ));
    }

ここで、$this->execute()の実装は、P.229にあり、以下のようになっています。

    public function execute($sql, $params = array())
    {
        $stmt = $this->con->prepare($sql);
        $stmt->execute($params);

        return $stmt;
    }

ここで、$this->conはPDOインスタンスなので、値を引数で渡してPDOのexecute()メソッドを実行することになります。

何が問題なのか?

実は、PDOStatement::execute()の引数で値を渡す場合、すべての値は文字列型として扱われる、という仕様があります。これは、PHPマニュアルにも明記されています。PDOStatement::execute()のパラメータは、以下のように説明されています。

実行される SQL 文の中のバインドパラメータと同数の要素からなる、 値の配列。すべての値は PDO::PARAM_STR として扱われます。
http://php.net/manual/ja/pdostatement.execute.php

ここで、PDO::PARAM_STRは、

SQL CHAR, VARCHAR, または他の文字列データ型を表します。
http://php.net/manual/ja/pdo.constants.php

とされています。

PDOStatement::execute()の引数で値を渡す場合の検証

実際に検証してみましょう(PHP 5.3.27 & MySQL 5.5.16)。

まず、テーブルを作成しておきます。

CREATE DATABASE sample DEFAULT CHARACTER SET utf8;

CREATE TABLE IF NOT EXISTS `example` (
  `id` INT(11) NOT NULL auto_increment,
  `language` VARCHAR(10),
  PRIMARY KEY  (`id`)
) DEFAULT CHARSET=utf8;

INSERT INTO `example` (`id`, `language`) VALUES
(1, 'PHP'), (2, 'Java'), (3, 'Ruby'), (4, 'Python'), (5, 'Perl');

次に、PDOのコードです。

try {
  $db = new PDO(
    'mysql:host=127.0.0.1;dbname=sample;charset=utf8'
    , 'root'
    , ''
  );

  echo 'MySQL: エミュレーション On';
  $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
  $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

  $int = 3; // 整数
  $str = 'Ruby';

  $prepare = $db->prepare('SELECT * FROM example WHERE id = :id and language = :lang');
  $prepare->execute(array(':id' => $int, ':lang' => $str));
  //var_dump($int, $str);

  $db = null;
} catch (Exception $e) {
  var_dump($e->getMessage());
}


try {
  $db = new PDO(
    'mysql:host=127.0.0.1;dbname=sample;charset=utf8'
    , 'root'
    , ''
  );

  echo 'MySQL: エミュレーション Off';
  $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
  $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

  $int = 3; // 整数
  $str = 'Ruby';

  $prepare = $db->prepare('SELECT * FROM example WHERE id = :id and language = :lang');
  $prepare->execute(array(':id' => $int, ':lang' => $str));
  //var_dump($int, $str);

  $db = null;
} catch (Exception $e) {
  var_dump($e->getMessage());
}

念のため、PDOでのプリペアドステートメントのエミュレーションをOnとOffの場合の2パターン記述してます。エミュレーションOnとは「安全なSQLの呼び出し方」での「動的プレースホルダ」のこと(PDO_MySQLでのデフォルト)、エミュレーションOffとは「静的プレースホルダ」のことです。

最後に、MySQLのmy.cnfでクエリログを取得するように設定しておきます。

general_log = 1
general_log_file = /opt/lampp/var/mysql/general.log

準備ができましたので、上記のコードを実行して、MySQLのクエリログを見てみましょう。

4 Connect   root@localhost on sample
4 Query SELECT * FROM example WHERE id = '3' and language = 'Ruby'
5 Connect   root@localhost on sample
5 Prepare   SELECT * FROM example WHERE id = ? and language = ?
4 Quit  
5 Execute   SELECT * FROM example WHERE id = '3' and language = 'Ruby'
5 Close stmt    
5 Quit  

4が最初の接続(動的プレースホルダ)、5が次の接続(静的プレースホルダ)ですが、どちらも結果として、以下のSQLが実行されていることがわかります。

SELECT * FROM example WHERE id = '3' and language = 'Ruby'

ここで注目すべきはid = '3'の部分です。3がシングルクォートで囲まれています。つまり、数値が文字列として扱われています。

何が本当の問題なのか?

やっと、たどり着きました。

つまり、数値を文字列として扱っているため、SQLでの暗黙の型変換が発生します。この問題の詳細は、徳丸さんの以下の記事を参照してください。

まとめ

多くのPHP解説書は、PDOのプリペアドステートメントに関して、数値列を文字列として扱ってしまうようなSQL文を実行しているサンプルコードが書いてあるのではないか?という疑問があります(ただし、『PHP逆引きレシピ 第2版』にはないはずです。万一、ありましたら見落としのバグなのでお知らせください。訂正を出します)。

少なくとも『パーフェクトPHP』にはそのようなサンプルコードがありました。

今日解説したPDOのワナ

  • PDOStatement::execute()の引数で値を渡す場合、すべての値は文字列型として扱われる

(2013-12-18 追記) どう解決したらいいかを、「PDOでの数値列の扱いにはワナがいっぱい(2)」に書きました。

明日は、ngyukiさんの「PHP の Windows 版でのみ発生する is_dir の奇妙な現象」です。

Tags: php, pdo

AspectMockでFuelPHPのアプリを100%テスト可能にする

FuelPHP Advent Calendar 2013の9日目です。昨日は、@sa2yasuさんの「FuelPHPを更に使ってみて使えるなと思った拡張ValidationRuleの書き方とCore拡張の小技」でした。

今日は、いま話題のAspectMockをFuelPHPで使い、FuelPHPのアプリを100%テストできないかというお話です。

ちなみに、本当に100%テストできるかどうかはまだ定かではありません。あと、カバー率を100%にすること自体は目的ではないので、あまり気にする必要はないと思います。

"Testability" should not be used as argument deciding what design pattern is right to use and what is not.

訳:どのデザインパターンが適切か否かという論拠に、「テスト可能性」が使われるべきではない。

これが、AspectMockからの主張です。

なお、この記事の前提環境は、以下のとおりです。

  • FuelPHP 1.7.1
  • AspectMock masterブランチ cc2be6945a705e65a2a4a12df7e35de82d0129f7 (2013-09-09)

準備

AspectMockと必要なライブラリをComposerで追加します。

diff --git a/composer.json b/composer.json
index e1b21ea..006ac5b 100644
--- a/composer.json
+++ b/composer.json
@@ -16,10 +16,14 @@
         "forum": "http://fuelphp.com/forums"
     },
     "require": {
-        "php": ">=5.3.3",
+        "php": ">=5.4",
         "monolog/monolog": "1.5.*",
        "fuelphp/upload": "2.0.1"
     },
+    "require-dev": {
+        "codeception/aspect-mock": "*",
+        "symfony/finder":"*"
+    },
     "suggest": {
         "mustache/mustache": "Allow Mustache templating with the Parser package",
         "smarty/smarty": "Allow Smarty templating with the Parser package",

インストールします。

$ php composer.phar update

FuelPHP 1.7.1の変更

テスト実行の場合、AspectMockを使うようにFuelPHPを変更します。

diff --git a/fuel/app/bootstrap.php b/fuel/app/bootstrap.php
index a6213d5..b491688 100644
--- a/fuel/app/bootstrap.php
+++ b/fuel/app/bootstrap.php
@@ -1,9 +1,5 @@
 <?php

-// Load in the Autoloader
-require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
-class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
-
 // Bootstrap the framework DO NOT edit this
 require COREPATH.'bootstrap.php';

diff --git a/oil b/oil
index 62033d6..4a21f80 100644
--- a/oil
+++ b/oil
@@ -48,6 +48,10 @@ define('COREPATH', realpath(__DIR__.'/fuel/core/').DIRECTORY_SEPARATOR);
 defined('FUEL_START_TIME') or define('FUEL_START_TIME', microtime(true));
 defined('FUEL_START_MEM') or define('FUEL_START_MEM', memory_get_usage());

+// Load in the Autoloader
+require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
+
 // Boot the app
 require APPPATH.'bootstrap.php';

diff --git a/public/index.php b/public/index.php
index e01d3a4..9cb90d3 100644
--- a/public/index.php
+++ b/public/index.php
@@ -40,6 +40,10 @@ define('COREPATH', realpath(__DIR__.'/../fuel/core/').DIRECTORY_SEPARATOR);
 defined('FUEL_START_TIME') or define('FUEL_START_TIME', microtime(true));
 defined('FUEL_START_MEM') or define('FUEL_START_MEM', memory_get_usage());

+// Load in the Autoloader
+require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
+
 // Boot the app
 require APPPATH.'bootstrap.php';

AspectMockがロードされるようにし、AscpectMockの設定をします($kernel->init()の部分)。ここが正しくないとAspectMockが正常に動作しません。

diff --git a/bootstrap_phpunit.php b/bootstrap_phpunit.php
index 3b5b851..2605850 100644
--- a/bootstrap_phpunit.php
+++ b/bootstrap_phpunit.php
@@ -32,6 +32,34 @@ unset($app_path, $core_path, $package_path, $_SERVER['app_path'], $_SERVER['core
 defined('FUEL_START_TIME') or define('FUEL_START_TIME', microtime(true));
 defined('FUEL_START_MEM') or define('FUEL_START_MEM', memory_get_usage());

+/**
+ * Load the Composer autoloader if present
+ */
+defined('VENDORPATH') or define('VENDORPATH', realpath(COREPATH.'..'.DS.'vendor').DS);
+if ( ! is_file(VENDORPATH.'autoload.php'))
+{
+   die('Composer is not installed. Please run "php composer.phar update" in the root to install Composer');
+}
+require VENDORPATH.'autoload.php';
+
+// Add AspectMock
+$kernel = \AspectMock\Kernel::getInstance();
+$kernel->init([
+   'debug' => true,
+   'appDir'    => __DIR__ . '/../',
+   'includePaths' => [
+       __DIR__.'/../app', __DIR__.'/../core', __DIR__.'/../packages',
+   ],
+   'excludePaths' => [
+       __DIR__.'/../app/tests', __DIR__.'/../core/tests',
+   ],
+]);
+
+// Load in the Autoloader
+//require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+$kernel->loadFile(COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php'); // path to your autoloader
+class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
+
 // Boot the app
 require_once APPPATH.'bootstrap.php';

(2014-03-21 追記) 最新のAspectMockだと、キャッシュフォルダがカレントディレクトリに作成されてしまうようなので、$kernel->init()の部分を以下に修正します。

$kernel->init([
    'debug' => true,
    'appDir' => __DIR__ . '/../',
    'includePaths' => [
        APPPATH, COREPATH, PKGPATH,
    ],
    'excludePaths' => [
        APPPATH.'tests', COREPATH.'tests'
    ],
    'cacheDir'     => APPPATH.'tmp/AspectMock',
]);

(2014-03-21 追記終了)

AspectMockが動作するように、fuel/core/phpunit.xmlをfuel/app/phpunit.xmlにコピーし、backupGlobalsをfalseに変更します。

--- fuel/core/phpunit.xml   2013-12-02 19:56:52.847375706 +0900
+++ fuel/app/phpunit.xml    2013-12-02 19:56:38.910935143 +0900
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>

-<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php">
+<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php" backupGlobals="false">
    <php>
        <server name="doc_root" value="../../"/>
        <server name="app_path" value="fuel/app"/>

FuelPHP 1.8/develop(1.7.2以降)の変更

1.8/developブランチでは、上記の変更が取り込まれていますので、fuel/coreのbootstrap_phpunit.phpを変更し、

diff --git a/bootstrap_phpunit.php b/bootstrap_phpunit.php
index 50b9c88..20678e7 100644
--- a/bootstrap_phpunit.php
+++ b/bootstrap_phpunit.php
@@ -40,8 +40,22 @@ if ( ! is_file(VENDORPATH.'autoload.php'))
 }
 require VENDORPATH.'autoload.php';

+// Add AspectMock
+$kernel = \AspectMock\Kernel::getInstance();
+$kernel->init([
+       'debug' => true,
+       'appDir' => __DIR__ . '/../',
+       'includePaths' => [
+               __DIR__.'/../app', __DIR__.'/../core', __DIR__.'/../packages',
+       ],
+       'excludePaths' => [
+               __DIR__.'/../app/tests', __DIR__.'/../core/tests',
+       ],
+]);
+
 // Load in the Fuel autoloader
-require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+//require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+$kernel->loadFile(COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php'); // path to your autoloader
 class_alias('Fuel\\Core\\Autoloader', 'Autoloader');

 // Boot the app

(2014-03-21 追記) 最新のAspectMockだと、キャッシュフォルダがカレントディレクトリに作成されてしまうようなので、$kernel->init()の部分を以下に修正します。

$kernel->init([
    'debug' => true,
    'appDir' => __DIR__ . '/../',
    'includePaths' => [
        APPPATH, COREPATH, PKGPATH,
    ],
    'excludePaths' => [
        APPPATH.'tests', COREPATH.'tests'
    ],
    'cacheDir'     => APPPATH.'tmp/AspectMock',
]);

(2014-03-21 追記終了)

fuel/app/phpunit.xmlを作成するだけでokです。

--- fuel/core/phpunit.xml   2013-12-02 19:56:52.847375706 +0900
+++ fuel/app/phpunit.xml    2013-12-02 19:56:38.910935143 +0900
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>

-<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php">
+<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php" backupGlobals="false">
    <php>
        <server name="doc_root" value="../../"/>
        <server name="app_path" value="fuel/app"/>

テストの書き方

コントローラからResponse::redirect()でリダイレクトする場合のテストを作成してみましょう。

Response::redirect()は安全のため内部でexit()しているため、そのままではテストがそこで終了してしまい、テストできません。これをテストダブルで置き換えて、テスト可能にします。

まず、以下のようなコントローラを作成します。

<?php

class Controller_Test extends Controller
{
    public function action_redirect()
    {
        return Response::redirect('welcome/404', 'location', 404);
    }
}

続いてテストを作成しましょう。

<?php

// AspectMockのTestクラスをtestとしてインポート
use AspectMock\Test as test;

/**
 * Tests for Controller_Test
 *
 * @group App
 * @group Controller
 */
class Test_Controller_Test extends TestCase
{
    protected function tearDown()
    {
        test::clean(); // 登録したテストダブルをすべて削除
    }

    public function test_redirect()
    {
        // Response::redirect()を単にtrueを返すテストダブルに置き換え
        $req = test::double('Fuel\Core\Response', ['redirect' => true]);

        // 'test/redirect'へのリクエストを生成
        $response = Request::forge('test/redirect')
                        ->set_method('GET')->execute()->response();

        // Response::redirect()が以下の引数で実行されたことを確認
        $req->verifyInvoked('redirect', ['welcome/404', 'location', 404]);
    }
}

テストを実行します。

$ oil test --group=App
Tests Running...This may take a few moments.
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /mnt/fuelphp/fuel/app/phpunit.xml

.

Time: 8.08 seconds, Memory: 48.50Mb

OK (1 test, 0 assertions)

通りました。「0 assertinos」というのがちょっと変ですが、PHPUnitの検証メソッドを使っていないため、いたしかたありません。

試しに、verifyInvoked()での第3引数の指定を404から405に変更してみます。

$req->verifyInvoked('redirect', ['welcome/404', 'location', 405]);
$ oil test --group=App
Tests Running...This may take a few moments.
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /mnt/fuelphp/fuel/app/phpunit.xml

F

Time: 7.72 seconds, Memory: 48.50Mb

There was 1 failure:

1) Test_Controller_Test::test_redirect
Expected Fuel\Core\Response::redirect('welcome/404','location',405) to be invoked but it never occur.

/mnt/fuelphp/fuel/vendor/codeception/aspect-mock/src/AspectMock/Proxy/Verifier.php:73
/mnt/fuelphp/fuel/app/tests/controller/test.php:28

FAILURES!
Tests: 1, Assertions: 0, Failures: 1.

正しく失敗しました。

このように、AspectMockを使うと静的メソッドをテストダブルに置き換えたり、メソッドを動的に再定義して、簡単にテストすることができます。AspectMockの主張どおり、テスト可能にするためだけにDIを使う必要はなくなります。

ただし、AspectMockは「Stability: alpha」となっており、まだ発展途上のツールのようですので期待したとおりに動作しない可能性はあります。

参考

おまけ

このFuelPHP Advent Calendarは今年で3年目ですが、一昨年、去年の分は、電子書籍化されてIT系の有名出版社より出版されています。

いずれも無料でダウンロードできますので、まだ、読んでいないFuelPHPユーザの方は、読んでみるといろいろな発見があると思いますよ。

明日は、@ounziwさんの「イベント機能を使ってアプリケーションをカスタマイズする」です。

関連

Tags: fuelphp, aspectmock, phpunit, testing

FuelPHP 1.7.1がリリースされました

バグ修正のFuelPHP 1.7.1が12/1にリリースされました。

セキュリティ修正を含むため、すべてのFuelPHPユーザは1.7.1へすみやかにアップグレードすることが推奨されます。アップグレードの前にChangeLogをよく確認しておきましょう。

なお、FuelPHP 1.x系は新機能の開発が終了しており、バグ修正のPull Requestのみが受け付けられます。ただし、新機能と呼べなくもない軽微な改良はマージされていますので、バグ修正以外の一切のPull Requestが受け付けられないわけではないようです。

1.7.1のバグ修正は1.8/developブランチに送信してください。1.8/developブランチは、1.x系の保守のために作成されたブランチです。

主な変更点

セキュリティ関連の修正は、http://fuelphp.com/security-advisoriesにある

  • SEC-CORE-003: $_GET not cleaned when parsed from REQUEST_URI

です。これは、$_GETが入力フィルタで処理されない場合があるものの修正です。

ただし、これ以外にも、Restコントローラでhtml形式での出力を許可している場合(デフォルトで許可されている)、XSSの可能性があったため、その部分の仕様が改良されています。従来は、Responseで配列を返す場合、var_dump()された結果が表示されていましたが、1.7.1からは本番環境では406を返すように、その他の環境では(デバッグのために)JSON形式で表示されるように変更されました。

目についた変更点を以下にピックアップしました。詳細は、ChangeLog v1.7.1を参照してください。

  • Sanitizationインターフェイスが導入されました。これは、ビューに渡すオブジェクトが自分自身でいわゆるサニタイズ処理をするためのインターフェイスです。ORMとModel_Crudで使われています。
  • Format::from_xml()はXML名前空間をサポートしました。
  • Format::to_json()は、JSONエンコーディングのオプション指定がデフォルトで安全なものに変更されています。設定でオプションを変更することも可能なはずでしたが、バグのため1.7.1では変更できません。1.8/developでは修正されています。
  • Input::uri()は、先頭にスラッシュを付けたURIを絶えず返すようになりました。
  • Inputクラスで、フォームのURLエンコードのダブルエンコーディングをコントロールできるようになりました。

『はじめてのフレームワークとしてのFuelPHP』

FuelPHP 1.7.1対応の追加情報が出ています。詳細は、以下のサポートサイトの「追加情報」を参照願います。

バグ情報

バグに関する情報は、以下を参照願います。

関連

Tags: fuelphp, release