From 0bf9e38c3a305a6f7f673ecc61c536cf666e6f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Tue, 13 Jan 2026 12:28:23 +0100 Subject: [PATCH] Implement rule for `unserialize` --- conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Php/PhpVersion.php | 5 + src/Rules/Functions/UnserializeRule.php | 148 ++++++++++++++++++ .../Rules/Functions/UnserializeRuleTest.php | 75 +++++++++ .../Rules/Functions/data/unserialize.php | 15 ++ .../Functions/data/unserialize_max_depth.php | 5 + 7 files changed, 250 insertions(+) create mode 100644 src/Rules/Functions/UnserializeRule.php create mode 100644 tests/PHPStan/Rules/Functions/UnserializeRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/unserialize.php create mode 100644 tests/PHPStan/Rules/Functions/data/unserialize_max_depth.php diff --git a/conf/config.neon b/conf/config.neon index 12d84e6234..f9de8ea399 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -84,6 +84,7 @@ parameters: reportPossiblyNonexistentConstantArrayOffset: false checkMissingOverrideMethodAttribute: false checkMissingOverridePropertyAttribute: %checkMissingOverrideMethodAttribute% + checkInsecureUnserialize: false mixinExcludeClasses: [] scanFiles: [] scanDirectories: [] diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index caef47711c..c8e5fc2c8d 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -92,6 +92,7 @@ parametersSchema: reportPossiblyNonexistentConstantArrayOffset: bool() checkMissingOverrideMethodAttribute: bool() checkMissingOverridePropertyAttribute: bool() + checkInsecureUnserialize: bool() parallel: structure([ jobSize: int(), processTimeout: float(), diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 6a555dff73..89671b228b 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -92,6 +92,11 @@ public function supportsReturnCovariance(): bool return $this->versionId >= 70400; } + public function supportsUnserializeMaxDepthOption(): bool + { + return $this->versionId >= 70400; + } + public function supportsNoncapturingCatches(): bool { return $this->versionId >= 80000; diff --git a/src/Rules/Functions/UnserializeRule.php b/src/Rules/Functions/UnserializeRule.php new file mode 100644 index 0000000000..2af8908bfd --- /dev/null +++ b/src/Rules/Functions/UnserializeRule.php @@ -0,0 +1,148 @@ + + */ +#[RegisteredRule(level: 5)] +final class UnserializeRule implements Rule +{ + + public function __construct( + private readonly PhpVersion $phpVersion, + private readonly ReflectionProvider $reflectionProvider, + #[AutowiredParameter] + private readonly bool $checkInsecureUnserialize, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($functionReflection->getName() !== 'unserialize') { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + if (count($args) !== 2) { + if ($this->checkInsecureUnserialize) { + return [ + RuleErrorBuilder::message( + 'Calling unserialize() without parameter $2 options and "allowed_classes" set to false or a list of allowed class names is insecure.', + )->identifier('unserialize.options.missing')->build(), + ]; + } + return []; + } + + $type = $scope->getType($args[1]->value); + $constantArrays = $type->getConstantArrays(); + if ($constantArrays === []) { + return []; + } + + $allowedClassesChecked = false; + $errors = []; + foreach ($constantArrays[0]->getValueTypes() as $i => $valueType) { + $key = $constantArrays[0]->getKeyTypes()[$i]->getValue(); + switch ($key) { + case 'allowed_classes': + $allowedClassesChecked = true; + if ($valueType->isBoolean()->yes()) { + if ($this->checkInsecureUnserialize && $valueType->isTrue()->yes()) { + $errors[] = RuleErrorBuilder::message( + 'Parameter #2 $options to function unserialize must either be false or a list of allowed class names.', + )->identifier('unserialize.allowedClasses.insecure')->build(); + } + continue 2; + } + $optionConstantArrays = $valueType->getConstantArrays(); + if ($valueType->isBoolean()->no() && $optionConstantArrays !== []) { + foreach ($optionConstantArrays[0]->getValueTypes() as $j => $itemType) { + $constantStrings = $itemType->getConstantStrings(); + if ($constantStrings !== []) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter #2 $options to function unserialize contains an invalid value for "allowed_classes" item #%d.', + $j + 1, + ))->identifier('unserialize.allowedClasses.invalidType')->build(); + } + } else { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter #2 $options to function unserialize contains an invalid value %s for "allowed_classes".', + $valueType->describe(VerbosityLevel::value()), + ))->identifier('unserialize.allowedClasses.invalidType')->build(); + } + break; + case 'max_depth': + if (!$this->phpVersion->supportsUnserializeMaxDepthOption()) { + $errors[] = RuleErrorBuilder::message( + 'Parameter #2 $options to function unserialize contains an option "max_depth" which is not supported by this PHP version.', + )->identifier('unserialize.maxDepth.unsupported')->build(); + } elseif ($valueType->isInteger()->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter #2 $options to function unserialize contains an invalid value %s for "max_depth".', + $valueType->describe(VerbosityLevel::value()), + ))->identifier('unserialize.maxDepth.invalidType')->build(); + } + break; + default: + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter #2 $options to function unserialize contains unsupported option "%s".', + $key, + ))->identifier('unserialize.unsupported')->build(); + } + } + if ($this->checkInsecureUnserialize && !$allowedClassesChecked) { + $errors[] = RuleErrorBuilder::message( + 'Parameter #2 $options to function unserialize must be present with "allowed_classes" set to false or a list of allowed class names.', + )->identifier('unserialize.allowedClasses.missing')->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Functions/UnserializeRuleTest.php b/tests/PHPStan/Rules/Functions/UnserializeRuleTest.php new file mode 100644 index 0000000000..aa2e138b81 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/UnserializeRuleTest.php @@ -0,0 +1,75 @@ + + */ +class UnserializeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new UnserializeRule(new PhpVersion(PHP_VERSION_ID), self::createReflectionProvider(), true); + } + + public function testFile(): void + { + $expectedErrors = [ + [ + 'Parameter #2 $options to function unserialize contains an invalid value for "allowed_classes" item #1.', + 5, + ], + [ + 'Parameter #2 $options to function unserialize contains an invalid value null for "allowed_classes".', + 7, + ], + [ + 'Parameter #2 $options to function unserialize contains an invalid value null for "max_depth".', + 9, + ], + [ + 'Parameter #2 $options to function unserialize must be present with "allowed_classes" set to false or a list of allowed class names.', + 9, + ], + [ + 'Parameter #2 $options to function unserialize contains unsupported option "foo".', + 11, + ], + [ + 'Parameter #2 $options to function unserialize must be present with "allowed_classes" set to false or a list of allowed class names.', + 11, + ], + [ + 'Parameter #2 $options to function unserialize must either be false or a list of allowed class names.', + 13, + ], + [ + 'Calling unserialize() without parameter $2 options and "allowed_classes" set to false or a list of allowed class names is insecure.', + 15, + ], + ]; + + $this->analyse([__DIR__ . '/data/unserialize.php'], $expectedErrors); + } + + #[RequiresPhp('< 7.4')] + public function testMaxDepth(): void + { + $expectedErrors = [ + [ + 'Parameter #2 $options to function unserialize contains an option "max_depth" which is not supported by this PHP version.', + 5, + ], + ]; + + $this->analyse([__DIR__ . '/data/unserialize_max_depth.php'], $expectedErrors); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/unserialize.php b/tests/PHPStan/Rules/Functions/data/unserialize.php new file mode 100644 index 0000000000..6642161971 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/unserialize.php @@ -0,0 +1,15 @@ + [null]]); + +unserialize($payload, ['allowed_classes' => null]); + +unserialize($payload, ['max_depth' => null]); + +unserialize($payload, ['foo' => null]); + +unserialize($payload, ['allowed_classes' => true]); + +unserialize($payload); diff --git a/tests/PHPStan/Rules/Functions/data/unserialize_max_depth.php b/tests/PHPStan/Rules/Functions/data/unserialize_max_depth.php new file mode 100644 index 0000000000..a8d5a24a87 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/unserialize_max_depth.php @@ -0,0 +1,5 @@ + false, 'max_depth' => 3]);