unserializeに変なデータを与えた場合、値によってバラバラな挙動になります。
unserialize('foo'); // E_NOTICE
unserialize('E:3:"foo";'); // E_WARNING + E_NOTICE
unserialize('O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;N;i:2;a:0:{}}'); // UnexpectedValueException
E_WARNING + E_NOTICEなんて特に意味がわかりませんね。
ということで、このあたりをどうにかしようというRFCが提出されました。
以下は該当のRFC、Improve unserialize() error handlingの紹介です。
PHP RFC: Improve unserialize() error handling
Introduction
現在、unserializeのエラーは一貫性がなく、制御が困難です。
与えた文字列によって、PHPはE_NOTICEかE_WARNINGか\Exceptionか\Errorか、もしくはこれらのうち複数を発生させます。
unserialize('foo'); // Notice: unserialize(): Error at offset 0 of 3 bytes
unserialize('i:12345678901234567890;'); // Warning: unserialize(): Numerical result out of range
unserialize('E:3:"foo";'); // Warning: unserialize(): Invalid enum name 'foo' (missing colon)
// Notice: unserialize(): Error at offset 0 of 10 bytes
unserialize('E:3:"fo:";'); // Warning: unserialize(): Class 'fo' not found
// Notice: unserialize(): Error at offset 0 of 10 bytes
try {
unserialize('O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;N;i:2;a:0:{}}');
} catch (UnexpectedValueException $e) {
echo $e->getMessage(), PHP_EOL; // Incomplete or ill-typed serialization data
}
try {
unserialize(
'a:2:{i:12345678901234567890;O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;N;i:2;a:0:{}}}'
); // Warning: unserialize(): Numerical result out of range
// Notice: unserialize(): Unexpected end of serialized data
// Notice: unserialize(): Error at offset 83 of 84 bytes
} catch (UnexpectedValueException $e) {
echo $e->getMessage(), PHP_EOL; // Incomplete or ill-typed serialization data
}
全てのエラーを確実に処理したい場合、現在は以下のようにしなければなりません。
try {
set_error_handler(static function ($severity, $message, $file, $line) {
throw new \ErrorException($message, 0, $severity, $file, $line);
});
$result = unserialize($serialized);
} catch (\Throwable $e) {
// unserializeに失敗した
} finally {
restore_error_handler();
}
var_dump($result); // 何かする
unserializeをエラーハンドラでキャッチする例は、実際にrestore_error_handlerのドキュメントで使用されています。
Proposal
この問題への対策は、2点で構成されています。
Add wrapper Exception 'UnserializationFailedException'
新たな例外\UnserializationFailedException
を追加します。
現在unserialize
関数およびマジックメソッド__unserialize()
で投げている例外は、全て\UnserializationFailedException
になります。
これによって、開発者はひとつの文catch(\UnserializationFailedException $e)
で、unserialize
中に発生する全てをキャッチできるようになります。
これによって全てのロジックをひとつのtryで囲むだけで済み、可読性を上げることができます。
unserialize
以外からUnserializationFailedExceptionが発生することはないので、無関係な例外を誤まって検出してしまうこともありません。
->getPrevious()
メソッドで、シリアライズが失敗した原因を知ることができます。
これらの変更をPHPコードとして表すと、おおよそ以下のようになります。
function unserialize(string $data, array $options = []): mixed
{
try {
// 現在のunserializeのロジック
} catch (\Throwable $e) {
throw new \UnserializationFailedException(previous: $e);
}
}
また、UnserializationFailedException
の実装は以下のとおりです。
/**
* @strict-properties
*/
class UnserializationFailedException extends \Exception
{
}
Increase the error reporting severity in the unserialize() parser
unserialize
ハンドラに辿り着く前に、文字列の構文エラー、範囲外の整数値を渡した、などの理由でunserialize
に失敗することがあります。
現在これらは例外にならず、かわりにE_NOTICEもしくはE_WARNING、あるいはその両方が発生します。
これらのエラーは統一する必要があるでしょう。
特にunserialize
ハンドラまで辿り着かずに失敗しているということは、安全でない操作が行われていることを意味するので、厳しい方向にすべきでしょう。
・serializeの出力にバイナリセーフではない変換や保存を行うと、シリアライズオブジェクトは完全に破壊されます。
・serializeの出力を文字コードの異なるストレージに保存すると、バイト長が変わる可能性があり、非ASCIIのシリアライズオブジェクトが読めなくなります。
・信頼できない入力をunserializeに渡すと、攻撃者は任意のエラーを発生させられるだけではなく、任意のリモートコードを実行される場合があります。
これらのケースに共通するものとして、シリアライズされた元のデータがそのままunserializeに渡ってきていないということです。
最も良かった場合、unserializeは単に失敗してfalseが返ります。
最も良くない場合、中身が想定外に書き換わります。
少なくともE_NOTICEはE_WARNINGに変更し、常にE_WARNINGが発生するようにすべきでしょう。
しかしそれよりも、単純にUnserializationFailedExceptionを投げるようにした方がより良い解決策かもしれません。
これまで例外ハンドリングをしてこなかったアプリケーションにおいて、この変更は急激なものかもしれません。
しかしそのようなアプリケーションも、そもそも元からThrowableをキャッチすることはできていなければなりません。
ここで一貫してUnserializationFailedExceptionを投げるようにすることで、統一されたエラー処理の利点を利用することができるようになります。
Resulting code
冒頭の例は、こう書けるようになります。
try {
$result = unserialize($serialized);
var_dump($result); // try中に他の処理を書いてもいい。
// こちらがエラーを出してもUnserializationFailureExceptionにはキャッチされない。
} catch (\UnserializationFailureException $e) {
// unserializeが失敗した
}
E_WARNINGとE_NOTICEがUnserializationFailureExceptionに引き上げられた場合でも全く同じです。
Backward Incompatible Changes
後方互換性のない変更点。
Addition of a new exception class
新たな例外クラス、UnserializationFailedExceptionは使用できなくなります。
GitHubで見つからなかったので、実質的に影響はないでしょう。
Existing error handling for unserialize() might need to be updated
エラーハンドリング方法が変わりますが、これは大きな問題にはならないでしょう。
現在のunserializeのエラーは一貫性がないため、全てをキャッチするためにはcatch(\Throwable)
としなければならないのですが、そうなっていれば今後もそのまま動作するからです。
Proposed PHP Version(s)
PHP8.3
RFC Impact
既存のエクステンションは、unserialize
のエラー処理を行っている場合は、より適切な例外を発するように変更してもよいでしょう。
igbinaryなどシリアライズ機能そのものをカスタムする拡張機能は、動作がデフォルトシリアライザと一致するように書き替える必要があるかもしれません。
Open Issues
既知の問題点は特にありません。
Unaffected PHP Functionality
unserialize()
と関連しない機能に影響はありません。
unserialize()
・__wakeup()
・Serializable::unserialize()
を使っている場合、その処理方法によっては影響を受けるかもしれません。
Future Scope
将来の展望であり、このRFCには含まれません。
・__PHP_Incomplete_Class
を作るかわりに例外を出す。 https://externals.io/message/118566#118613
・ini設定unserialize_callback_func
をPHP側から設定できるようにする。 https://externals.io/message/118566#118672
・末尾の異常データをエラーにする。 https://github.com/php/php-src/pull/9630
Proposed Voting Choices
投票者の2/3の賛成で受理されます。
投票期間は2022/09/22から2022/10/07です。
『PHP8.3で例外UnserializationFailedExceptionを追加する』『PHP8.3でE_NOTICEをE_WARNINGにする』『PHP9.0でE_WARNINGをUnserializationFailedExceptionにする』それぞれについて投票が行われました。
『PHP8.3でE_NOTICEをE_WARNINGにする』は賛成33反対2で受理、『PHP9.0でE_WARNINGをUnserializationFailedExceptionにする』は賛成23反対8で受理されました。
『PHP8.3で例外UnserializationFailedExceptionを追加する』は賛成20反対12で却下されました。
References
Exceptionの実装
NoticeをWARNINGにする
感想
DateTimeに引き続き、unserializeもいいかんじにエラーハンドリングできるようになります。
UnserializationFailedExceptionが導入されると、たとえはSymfonyでは30行程度の修正が必要になります。
そんなわけでBCbreakの多いUnserializationFailedExceptionの導入はPHP9になったようです。
今後はぜひ、高機能になったExceptionを使っていいかんじのエラーハンドリングをしてみましょう。
まあ私は面倒なんで『unserializeできんかった』一言で終わらせると思いますが。