PHP7では整数型、浮動小数点型、配列型のタイプヒントが追加されます。データ型をより厳格に取り扱うようになるのは良い事ですが、データ型を変換してしまうため問題となる場合もあります。
データ型は指定した型に変換すればよい、という単純な物ではありません。私はデータ型を変換しない方のRFCを支持していました。残念ながらこちらのRFCでなく、問題が多い方のRFCが採用されることになりました。
タイプヒントとは?
そもそもタイプヒントを使ったことが無い方も多いと思います。PHPはオブジェクトのクラスを「タイプヒント」として指定することが従来から可能でした。例えば、
0 1 2 3 4 |
function (MyClass $obj) { // Do something } |
のようにタイプヒントとして”MyClass”を指定し、 $objが”MyClass”または”MyClass”の派生型でなければエラーを発生させることができました。
もう正確なバージョンを忘れるほど随分前からオブジェクトのタイプヒントは指定できたのですが、整数型/浮動小数点型/配列型にはタイプヒントを指定できませんでした。これらの基本的なデータ型のタイプヒントが利用できなかった理由は”PHPのデータ型は弱いデータ型”であり、コンテクストに応じて自動的に型変換が行われることにありました。
CやJavaのような厳格なタイプヒントを好む人、より緩いタイプヒントを好む人、いろいろな意見を持つ人に分かれていたため基本的なデータ型のタイプヒントは導入されませんでした。PHP7からは基本的なデータ型にもタイプヒントが利用できるようになります。
PHP7で採用されたタイプヒントと採用されなかったタイプヒントの違い
整数型、浮動小数点型タイプヒントには幾つかの提案(RFC)がありました。今回、投票になった提案は次の2つです。
PHP7で採用されるタイプヒント(スカラータイプヒント)
採用されなかったタイプヒント (強制タイプヒント)
最も重要な違いは「データ型を変換」するかしないかです。採用されなかったRFCではデータ型は変換されません。
PHPは型が緩い言語であるため、PHPのネイティブデータ型の変数が表現できない範囲の数値でも文字列として渡すことで問題なく処理できました。しかし、PHP7で採用されるタイプヒントでは「データ型の変換」が必ず発生します。
以下は現在のgitマスターブランチのPHPでの実行結果です。
0 1 2 3 4 5 6 7 |
<?php function foo(int $val) { var_dump($val); } foo('123'); |
結果
0 1 2 |
int(123) |
なぜデータ型を変換してはならないのか?
最初に記載したように「データ型は単純に変換してはならない」です。
データ型を変換する場合、
- 入力値の内容
- 出力先のデータ仕様
これらを検証/考慮した上で変換を行わなければなりません。
整数型、浮動小数点型には表現可能な範囲があります。この範囲を超える値を変換すると問題の原因となります。PHP7のタイプヒントでは範囲外となるデータではエラーとなります。
0 1 2 3 4 5 6 7 |
<?php function foo(int $val) { var_dump($val); } foo('9999999999999999999999999999999999'); |
結果
0 1 2 |
Fatal error: Argument 1 passed to foo() must be of the type integer, string given, called in - on line 6 and defined in - on line 2 |
どちらのRFCでも不正なパラメータでE_RECOVERABLE_ERRORが発生するので、エラーをプログラマが処理することが可能です。ただし、採用されなかったRFCでは形式のみをチェックし範囲外のデータはエラー従来通りエラーになりません。
「データ型は単純に変換してはならない」を理解するには、以下のようなSQLクエリとPHPコードを考えてください。
0 1 2 |
$sql = "SELECT * FROM some_table WHERE id = ".(int)$id.";"; |
このコードは正しいでしょうか?
データベースのidカラムがPHPの$idで表現できる範囲内の値であれば「正しい」です。しかし、データベースのidカラムはPHPの整数型のように「符号付き整数」とは限りません。
例えば、MySQLは符号無し整数をサポートしています。上記のようなコードでは正しく動作しません。
MySQLで符号無し64 bit整数idを作成する例:
0 1 2 3 |
MariaDB [test]> CREATE TABLE test (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, PRIMARY KEY (id)); Query OK, 0 rows affected (0.29 sec) |
PHPの整数型は符号付き整数です。符号無し64 bit整数をサポートするデータベースの場合、PHPが64bit環境だからといって例のSQL文生成コードは正しいコードになりません。
SQLiteの場合、データ型は型親和性ルールで処理されるのでどのように大きな数値でもNUMERIC/DECIMALのように処理します。
また32 bit環境の場合、PHPの整数型は符号付き32 bit整数になります。浮動小数点型は32/64 bit環境両方ともIEEE 754 倍精度浮動小数点ですが浮動小数点型も同じような問題を抱えています。※
※ 例えば、PostgreSQLのNUMERIC型はかなり大きな数値を表現できます。
numeric | 可変長 | ユーザ指定精度、正確 | 小数点前までは131072桁、小数点以降は16383桁 |
セキュリティとの関係
データ型の変換は単なるバグの原因となるだけでなく、セキュリティ問題の原因にもなります。
前提条件のないキャスト
PHP7のタイプヒントでは”int”や”float”へのキャストが必要になります。特にdeclare(scrict_types=1)を設定した場合、データ型がタイプヒントと一致することが要求され、キャストが必須になります。
0 1 2 3 4 5 6 7 8 9 |
<?php declare(strict_types=1); function foo(int $i) { var_dump($i); } foo('123'); |
結果
0 1 2 |
Fatal error: Argument 1 passed to foo() must be of the type integer, string given, called in - on line 8 and defined in - on line 4 |
しかし、PHPのキャストはエラーを発生しません。
0 1 2 3 4 5 6 |
$ ./sapi/cli/php -d error_reporting=-1 -r 'echo (int)2342342342342342342342342;' -6773393222916898816 $ ./sapi/cli/php -d error_reporting=-1 -r 'echo (int)"abc";' 0 |
整数の0や負の整数が特別な意味を持つコードは少くありません。前提条件、つまり事前のバリデーションなくキャストし、パラメータの値が0や負の値になった場合に本来実行できないはずのコードが実行できると、セキュリティ問題の原因となるバグとなります。
タイプヒントによるE_RECOVERABLE_ERROR
タイプヒントが無い場合、データベースの整数型レコードIDなどの入力データは”妥当な整数形式”文字列であるかのみをチェックし、クエリ結果を確認すれば十分でした。Weak Mode(declare(strict_type=1)を宣言しない場合)でレコードIDに符号無し整数型を利用し”int”タイプヒントを使用した場合、前述の通り本来は正しく動作すべき妥当なデータであったとしても、PHPの”int”型で表現できない入力としてE_RECOVERABLE_ERRORが発生します。
0 1 2 3 4 5 6 7 |
<?php function foo(int $val) { var_dump($val); } foo('9999999999999999999999999999999999'); |
結果
0 1 2 |
Fatal error: Argument 1 passed to foo() must be of the type integer, string given, called in - on line 6 and defined in - on line 2 |
このためデータベースの整数型レコードIDなどがオーバーフローしていないにも関わらず、システムが利用できなくなるDoS(サービス不能)状態になることも有り得ます。特に32 bit環境ではこのような問題のある動作に容易になってしまうことに注意が必要です。
誤ったデータ型の仮定
強い型付け言語で時々見られる脆弱性で、何らかの処理内でパラメータが整数などとして取り扱われる場合、その関数/メソッドをエラーなく処理できれば、パラメータには不正な文字列などが含まれないと仮定するコードがあります。PHPでも同じようなコードが書かれる可能性があります。
0 1 2 3 4 5 6 7 |
<?php function foo(int $i) { var_dump($i); } foo('124 abcde'); |
出力
0 1 2 3 |
Notice: A non well formed numeric value encountered in - on line 2 int(124) |
E_NOTICEエラーは発生するのですが、これを無視して処理を続行し、整数/浮動小数点パラメータとして渡した変数が数値だと仮定した処理が行われるとセキュリティ問題の原因になります。従来から「PHPのエラーが発生した場合、全て致命的なエラーとして処理できるコードにすべきです」とアドバイスしてきました。PHP7のタイプヒントでは、開発者が誤って安全だと仮定してしまうリスクが増えます。このため、適切なエラー処理の重要性が増します。
整数型、浮動小数点型タイプヒントの用途には制限がある
採用されなかったRFCの場合、データ型を変換せず元のデータを保持するので表現可能な範囲を気にせず利用できました。しかし、採用されたRFCではデータ型を変換するためタイプヒントの利用範囲はより限定されます。以下のような点に注意しなければなりません。
- データベースの数値型カラムデータ型との整合性
- JSONなどの数値型データとの整合性
- その他、全ての外部入力変数の表現可能範囲とタイプヒントととの整合性
通常、データベースの整数型レコードIDなどを利用して演算することはほとんどなく、取得したIDをそのまま利用するだけです。データベースがサポートするID値(普通は文字列型)であればPHPの整数データ型で表現可能な範囲など気にせず利用できました。タイプヒントで”int”を利用する場合、int型で表現可能な範囲を意識しないと問題の原因になります。
備考:採用されなかったタイプヒントRFCであれば、これまで通りデータベースなどが返してくる数値(普通は文字列)をそのまま利用できた上、不正な形式のデータが渡された場合に的確なエラーが発生しました。このRFCの弱点はデータ型のチェックが何度も発生することですが、これは私が提案した型親和性RFCでほぼ無視可能でした。
開発者はどうすべきか?
既に決まってしまったことで覆せないので仕方ありません。タイプヒントを利用すると、キャストの利用が必要となる場面が増え、今までデータ型を気にせず使えていた部分でもしっかりデータ型を意識して使う必要があります。データ型を意識するには入力値のバリデーション/確認が必要です。キャストする前に入力値をバリデーション、確認(入力ミスチェック)することの重要性がより増します。
PHP7用アプリケーションに限らず、入力値のバリデーション、確認が甘いアプリケーションは多くあります。コードを書く場合、入力値のバリデーション/確認を確実に行うようにすれば良いです。今、PHP7を使っていなくても変換可能なデータ型の変換を入力値のバリデーション/確認で行っておけばスムースにより移行できます。
データベースレコード、JSONデータのIDや数値など文字列型で返される変数は、これまで通りプログラマが文字列としてバリデーションし、タイプヒントを利用する場合はstringを利用すべきです。特にポータブルなライブラリを書く開発者はこのように心がけなければなりません。
しかし、
0 1 2 |
$sql = "SELECT * FROM some_table WHERE id = ".(int)$id.";"; |
のようなコードは比較的よく見かけるので、全てのライブラリ作者が実践することはかなり難しいかも知れません。
タイプヒントを使う場合の注意点:
- 外部システムの整数値を取り扱う場合には表現可能な範囲に注意して”int”, “float”タイプヒントを利用する
- “int”, “float”で表現可能な範囲外の場合、”string”タイプヒントを使い従来通りバリデーションを行う
- 型エラーを無くす目的やサニタイズ目的で、何も考えずにint, floatにキャストすることは厳禁(キャストではエラーが発生しない)
- 通常はエラーは一切発生しないコードを書き、エラーが発生した場合は致命的なエラーとして処理を終了する
特に「何も考えないでキャスト」は既に解説した通りセキュリティ問題の原因になります。基本的に「何も考えないでキャスト」は行ってはならない処理だと覚えておくと良いです。
まとめ
今後はPHP7のタイプヒントを好む、好まないに関係なく、ライブラリなどでタイプヒントが利用されるようになると考えられます。自分のコードでデータを良く理解した上でキャストするのであれば良いのですが、ライブラリが別のライブラリを利用し、型エラーを無くすために何も考えずにキャストしている、といったケースも考えられます。そもそもOSSのライブラリを利用する場合、ソースコードを確認してから使うべきですが、その必要性は益々大きくなると予想されます。
「何も考えないでキャストしているコード」=「危険なコード」と思っても構わないでしょう。
採用されなかった強制タイプヒントRFCと型親和性RFC(こちらはまだ投票は行われていません)であれば、採用されたタイプヒントほどあれこれ考えなくても、つまりキャストしなくても、正しくかつ効率的に実行でき、内部関数やクラスと動作の差異も将来同じにすれば整合性もあり使いやすい物にできました。おまけにユーザーコード中で発見が難しいバグも見つけられる、というメリットもありました。問題の多いRFC方が採用されたことは非常に残念です。
備考:データベースの整数型レコードIDなどを例外として”string型”を用いなければならないのは採用されたRFCの欠点です。強制タイプヒントRFCであればPHPの整数型より大きな範囲を持つ整数型レコードIDにも”int型”を用いることができました。
しかし、良い面もあります。データ型の整合性をプログラム処理の中で保つことは骨が折れます。入力バリデーション/確認処理の一貫として入力処理時にデータ型を変換してしまう方が楽です。入力バリデーションが甘いアプリケーションが多いので、より厳格な入力バリデーション/確認処理を推奨するような仕様になった、とも言えます。
型親和性RFCが採用された場合、整数型/浮動小数点型で表現可能なデータを簡単にPHPネイティブのデータ型に変換できます。これらの型で表現可能な範囲のデータであれば
0 1 2 3 4 5 6 |
if (!is_int($_GET['age']) || $_GET['age'] < 0 || $_GET['age'] > 130) { // Cannot be valid trigger_error('Invalid age('.$_GET['age'].')'); exit; } |
のような形でより簡単にバリデーションできるようになります。文字列形式の数値の演算が多用されているようなコードの場合、型親和性を利用したデータ型変換で実行効率の改善も期待できます。型親和性RFCは強制タイプヒントRFCの方が相性が良いのですが、問題の多い方のRFCでも有用です。時間を作ってモジュールを作成したいので、もし作ったら使ってみてください。