Skip to content

Commit 0bf9e38

Browse files
Implement rule for unserialize
1 parent 79c0309 commit 0bf9e38

File tree

7 files changed

+250
-0
lines changed

7 files changed

+250
-0
lines changed

conf/config.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ parameters:
8484
reportPossiblyNonexistentConstantArrayOffset: false
8585
checkMissingOverrideMethodAttribute: false
8686
checkMissingOverridePropertyAttribute: %checkMissingOverrideMethodAttribute%
87+
checkInsecureUnserialize: false
8788
mixinExcludeClasses: []
8889
scanFiles: []
8990
scanDirectories: []

conf/parametersSchema.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ parametersSchema:
9292
reportPossiblyNonexistentConstantArrayOffset: bool()
9393
checkMissingOverrideMethodAttribute: bool()
9494
checkMissingOverridePropertyAttribute: bool()
95+
checkInsecureUnserialize: bool()
9596
parallel: structure([
9697
jobSize: int(),
9798
processTimeout: float(),

src/Php/PhpVersion.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ public function supportsReturnCovariance(): bool
9292
return $this->versionId >= 70400;
9393
}
9494

95+
public function supportsUnserializeMaxDepthOption(): bool
96+
{
97+
return $this->versionId >= 70400;
98+
}
99+
95100
public function supportsNoncapturingCatches(): bool
96101
{
97102
return $this->versionId >= 80000;
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\ArgumentsNormalizer;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\DependencyInjection\AutowiredParameter;
10+
use PHPStan\DependencyInjection\RegisteredRule;
11+
use PHPStan\Php\PhpVersion;
12+
use PHPStan\Reflection\ParametersAcceptorSelector;
13+
use PHPStan\Reflection\ReflectionProvider;
14+
use PHPStan\Rules\Rule;
15+
use PHPStan\Rules\RuleErrorBuilder;
16+
use PHPStan\Type\VerbosityLevel;
17+
use function count;
18+
use function sprintf;
19+
20+
/**
21+
* @implements Rule<Node\Expr\FuncCall>
22+
*/
23+
#[RegisteredRule(level: 5)]
24+
final class UnserializeRule implements Rule
25+
{
26+
27+
public function __construct(
28+
private readonly PhpVersion $phpVersion,
29+
private readonly ReflectionProvider $reflectionProvider,
30+
#[AutowiredParameter]
31+
private readonly bool $checkInsecureUnserialize,
32+
)
33+
{
34+
}
35+
36+
public function getNodeType(): string
37+
{
38+
return FuncCall::class;
39+
}
40+
41+
public function processNode(Node $node, Scope $scope): array
42+
{
43+
if (!($node->name instanceof Node\Name)) {
44+
return [];
45+
}
46+
47+
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
48+
return [];
49+
}
50+
51+
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
52+
if ($functionReflection->getName() !== 'unserialize') {
53+
return [];
54+
}
55+
56+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
57+
$scope,
58+
$node->getArgs(),
59+
$functionReflection->getVariants(),
60+
$functionReflection->getNamedArgumentsVariants(),
61+
);
62+
63+
$normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node);
64+
if ($normalizedFuncCall === null) {
65+
return [];
66+
}
67+
68+
$args = $normalizedFuncCall->getArgs();
69+
if (count($args) !== 2) {
70+
if ($this->checkInsecureUnserialize) {
71+
return [
72+
RuleErrorBuilder::message(
73+
'Calling unserialize() without parameter $2 options and "allowed_classes" set to false or a list of allowed class names is insecure.',
74+
)->identifier('unserialize.options.missing')->build(),
75+
];
76+
}
77+
return [];
78+
}
79+
80+
$type = $scope->getType($args[1]->value);
81+
$constantArrays = $type->getConstantArrays();
82+
if ($constantArrays === []) {
83+
return [];
84+
}
85+
86+
$allowedClassesChecked = false;
87+
$errors = [];
88+
foreach ($constantArrays[0]->getValueTypes() as $i => $valueType) {
89+
$key = $constantArrays[0]->getKeyTypes()[$i]->getValue();
90+
switch ($key) {
91+
case 'allowed_classes':
92+
$allowedClassesChecked = true;
93+
if ($valueType->isBoolean()->yes()) {
94+
if ($this->checkInsecureUnserialize && $valueType->isTrue()->yes()) {
95+
$errors[] = RuleErrorBuilder::message(
96+
'Parameter #2 $options to function unserialize must either be false or a list of allowed class names.',
97+
)->identifier('unserialize.allowedClasses.insecure')->build();
98+
}
99+
continue 2;
100+
}
101+
$optionConstantArrays = $valueType->getConstantArrays();
102+
if ($valueType->isBoolean()->no() && $optionConstantArrays !== []) {
103+
foreach ($optionConstantArrays[0]->getValueTypes() as $j => $itemType) {
104+
$constantStrings = $itemType->getConstantStrings();
105+
if ($constantStrings !== []) {
106+
continue;
107+
}
108+
$errors[] = RuleErrorBuilder::message(sprintf(
109+
'Parameter #2 $options to function unserialize contains an invalid value for "allowed_classes" item #%d.',
110+
$j + 1,
111+
))->identifier('unserialize.allowedClasses.invalidType')->build();
112+
}
113+
} else {
114+
$errors[] = RuleErrorBuilder::message(sprintf(
115+
'Parameter #2 $options to function unserialize contains an invalid value %s for "allowed_classes".',
116+
$valueType->describe(VerbosityLevel::value()),
117+
))->identifier('unserialize.allowedClasses.invalidType')->build();
118+
}
119+
break;
120+
case 'max_depth':
121+
if (!$this->phpVersion->supportsUnserializeMaxDepthOption()) {
122+
$errors[] = RuleErrorBuilder::message(
123+
'Parameter #2 $options to function unserialize contains an option "max_depth" which is not supported by this PHP version.',
124+
)->identifier('unserialize.maxDepth.unsupported')->build();
125+
} elseif ($valueType->isInteger()->no()) {
126+
$errors[] = RuleErrorBuilder::message(sprintf(
127+
'Parameter #2 $options to function unserialize contains an invalid value %s for "max_depth".',
128+
$valueType->describe(VerbosityLevel::value()),
129+
))->identifier('unserialize.maxDepth.invalidType')->build();
130+
}
131+
break;
132+
default:
133+
$errors[] = RuleErrorBuilder::message(sprintf(
134+
'Parameter #2 $options to function unserialize contains unsupported option "%s".',
135+
$key,
136+
))->identifier('unserialize.unsupported')->build();
137+
}
138+
}
139+
if ($this->checkInsecureUnserialize && !$allowedClassesChecked) {
140+
$errors[] = RuleErrorBuilder::message(
141+
'Parameter #2 $options to function unserialize must be present with "allowed_classes" set to false or a list of allowed class names.',
142+
)->identifier('unserialize.allowedClasses.missing')->build();
143+
}
144+
145+
return $errors;
146+
}
147+
148+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PHPStan\Php\PhpVersion;
6+
use PHPStan\Rules\Rule;
7+
use PHPStan\Testing\RuleTestCase;
8+
use PHPUnit\Framework\Attributes\RequiresPhp;
9+
use const PHP_VERSION_ID;
10+
11+
/**
12+
* @extends RuleTestCase<UnserializeRule>
13+
*/
14+
class UnserializeRuleTest extends RuleTestCase
15+
{
16+
17+
protected function getRule(): Rule
18+
{
19+
return new UnserializeRule(new PhpVersion(PHP_VERSION_ID), self::createReflectionProvider(), true);
20+
}
21+
22+
public function testFile(): void
23+
{
24+
$expectedErrors = [
25+
[
26+
'Parameter #2 $options to function unserialize contains an invalid value for "allowed_classes" item #1.',
27+
5,
28+
],
29+
[
30+
'Parameter #2 $options to function unserialize contains an invalid value null for "allowed_classes".',
31+
7,
32+
],
33+
[
34+
'Parameter #2 $options to function unserialize contains an invalid value null for "max_depth".',
35+
9,
36+
],
37+
[
38+
'Parameter #2 $options to function unserialize must be present with "allowed_classes" set to false or a list of allowed class names.',
39+
9,
40+
],
41+
[
42+
'Parameter #2 $options to function unserialize contains unsupported option "foo".',
43+
11,
44+
],
45+
[
46+
'Parameter #2 $options to function unserialize must be present with "allowed_classes" set to false or a list of allowed class names.',
47+
11,
48+
],
49+
[
50+
'Parameter #2 $options to function unserialize must either be false or a list of allowed class names.',
51+
13,
52+
],
53+
[
54+
'Calling unserialize() without parameter $2 options and "allowed_classes" set to false or a list of allowed class names is insecure.',
55+
15,
56+
],
57+
];
58+
59+
$this->analyse([__DIR__ . '/data/unserialize.php'], $expectedErrors);
60+
}
61+
62+
#[RequiresPhp('< 7.4')]
63+
public function testMaxDepth(): void
64+
{
65+
$expectedErrors = [
66+
[
67+
'Parameter #2 $options to function unserialize contains an option "max_depth" which is not supported by this PHP version.',
68+
5,
69+
],
70+
];
71+
72+
$this->analyse([__DIR__ . '/data/unserialize_max_depth.php'], $expectedErrors);
73+
}
74+
75+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
$payload = 'b:0;';
4+
5+
unserialize($payload, ['allowed_classes' => [null]]);
6+
7+
unserialize($payload, ['allowed_classes' => null]);
8+
9+
unserialize($payload, ['max_depth' => null]);
10+
11+
unserialize($payload, ['foo' => null]);
12+
13+
unserialize($payload, ['allowed_classes' => true]);
14+
15+
unserialize($payload);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
$payload = 'b:0;';
4+
5+
unserialize($payload, ['allowed_classes' => false, 'max_depth' => 3]);

0 commit comments

Comments
 (0)