Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ parameters:
reportPossiblyNonexistentConstantArrayOffset: false
checkMissingOverrideMethodAttribute: false
checkMissingOverridePropertyAttribute: %checkMissingOverrideMethodAttribute%
checkInsecureUnserialize: false
mixinExcludeClasses: []
scanFiles: []
scanDirectories: []
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ parametersSchema:
reportPossiblyNonexistentConstantArrayOffset: bool()
checkMissingOverrideMethodAttribute: bool()
checkMissingOverridePropertyAttribute: bool()
checkInsecureUnserialize: bool()
parallel: structure([
jobSize: int(),
processTimeout: float(),
Expand Down
5 changes: 5 additions & 0 deletions src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
148 changes: 148 additions & 0 deletions src/Rules/Functions/UnserializeRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Php\PhpVersion;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\VerbosityLevel;
use function count;
use function sprintf;

/**
* @implements Rule<Node\Expr\FuncCall>
*/
#[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;
}

}
75 changes: 75 additions & 0 deletions tests/PHPStan/Rules/Functions/UnserializeRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;
use const PHP_VERSION_ID;

/**
* @extends RuleTestCase<UnserializeRule>
*/
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('< 8.0')]
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);
}

}
15 changes: 15 additions & 0 deletions tests/PHPStan/Rules/Functions/data/unserialize.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

$payload = 'b:0;';

unserialize($payload, ['allowed_classes' => [null]]);

unserialize($payload, ['allowed_classes' => null]);

unserialize($payload, ['max_depth' => null]);

unserialize($payload, ['foo' => null]);

unserialize($payload, ['allowed_classes' => true]);

unserialize($payload);
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Functions/data/unserialize_max_depth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

$payload = 'b:0;';

unserialize($payload, ['max_depth' => 3]);
Loading