Skip to content

Commit e6a7144

Browse files
eliashaeusslergarvinhicking
authored andcommitted
[TASK] Implement and apply custom PHPStan rule to harden unserialize
This patch integrates a custom PHPStan rule to detect unsecure or invalid calls to the `unserialize` function. The rule was copied over from [1] and may get removed again once it is merged into PHPStan core. As a consequence, remaining potentially insecure `unserialize` calls are covered by `@phpstan-ignore` annotations. This affects the following code parts: * `AuthenticatedMessageDeserializer`: Doing `unserialize` with `['allowed_classes' => true]` at this point is intended, as the relevant integrity check already happens via HMAC validation. * `ActionController`: Same here (HMAC validation) * `FormRuntime`: Same here (HMAC validation) * Tests: Doing insecure `unserialize` in tests is currently ignored, because the effect of having potential security leaks is extreme low. To allow PHPStan to properly detect the types used for `unserialize` in `PackageCacheEntry`, this class now receives higher type coverage. [1]: phpstan/phpstan-src#4754 Resolves: #110120 Releases: main, 14.3, 13.4 Change-Id: I04255241d697703c27ad6c557b8f917f0a70e329 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/94614 Tested-by: Oliver Klee <typo3-coding@oliverklee.de> Reviewed-by: Garvin Hicking <garvin@hick.ing> Tested-by: core-ci <typo3@b13.com> Tested-by: Garvin Hicking <garvin@hick.ing> Reviewed-by: Oliver Hader <oliver.hader@typo3.org> Tested-by: Oliver Hader <oliver.hader@typo3.org> Reviewed-by: Simon Schaufelberger <simonschaufi+typo3@gmail.com>
1 parent 2d7c8b1 commit e6a7144

12 files changed

Lines changed: 260 additions & 3 deletions

File tree

‎Build/phpstan/phpstan.neon‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ services:
2525
- 'TYPO3\CMS\Frontend\Tests'
2626
tags:
2727
- phpstan.rules.rule
28+
-
29+
class: \TYPO3\CMS\PHPStan\Rules\Classes\UnserializeRule
30+
tags:
31+
- phpstan.rules.rule
2832

2933
parameters:
3034
# Use local .cache dir instead of /tmp
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\PHPStan\Rules\Classes;
19+
20+
use PhpParser\Node;
21+
use PhpParser\Node\Expr\FuncCall;
22+
use PHPStan\Analyser\ArgumentsNormalizer;
23+
use PHPStan\Analyser\Scope;
24+
use PHPStan\Php\PhpVersion;
25+
use PHPStan\Reflection\ParametersAcceptorSelector;
26+
use PHPStan\Reflection\ReflectionProvider;
27+
use PHPStan\Rules\Arrays\AllowedArrayKeysTypes;
28+
use PHPStan\Rules\Rule;
29+
use PHPStan\Rules\RuleError;
30+
use PHPStan\Rules\RuleErrorBuilder;
31+
use PHPStan\Type\ArrayType;
32+
use PHPStan\Type\BooleanType;
33+
use PHPStan\Type\ClassStringType;
34+
use PHPStan\Type\Constant\ConstantBooleanType;
35+
use PHPStan\Type\Type;
36+
use PHPStan\Type\TypeCombinator;
37+
use PHPStan\Type\VerbosityLevel;
38+
39+
/**
40+
* Custom PHPStan rule to harden `unserialize` calls.
41+
*
42+
* Extracted from https://github.com/phpstan/phpstan-src/pull/4754, may become
43+
* obsolete once this is merged upstream in PHPStan, and can be removed then.
44+
*
45+
* @implements Rule<FuncCall>
46+
*/
47+
final readonly class UnserializeRule implements Rule
48+
{
49+
public function __construct(
50+
private PhpVersion $phpVersion,
51+
private ReflectionProvider $reflectionProvider,
52+
) {}
53+
54+
public function getNodeType(): string
55+
{
56+
return FuncCall::class;
57+
}
58+
59+
public function processNode(Node $node, Scope $scope): array
60+
{
61+
if (!($node->name instanceof Node\Name)) {
62+
return [];
63+
}
64+
65+
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
66+
return [];
67+
}
68+
69+
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
70+
71+
// Skip unsupported functions
72+
if ($functionReflection->getName() !== 'unserialize') {
73+
return [];
74+
}
75+
76+
// Normalize function arguments
77+
$normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments(
78+
ParametersAcceptorSelector::selectFromArgs(
79+
$scope,
80+
$node->getArgs(),
81+
$functionReflection->getVariants(),
82+
$functionReflection->getNamedArgumentsVariants(),
83+
),
84+
$node,
85+
);
86+
87+
// Skip on invalid function signatures
88+
if ($normalizedFuncCall === null) {
89+
return [];
90+
}
91+
92+
$args = $normalizedFuncCall->getArgs();
93+
94+
// Fail if $options array is missing in unserialize call
95+
if (count($args) !== 2) {
96+
return [
97+
RuleErrorBuilder::message(
98+
'Calling unserialize() without parameter $2 options and "allowed_classes" set to false or a list of allowed class names is insecure.',
99+
)->identifier('unserialize.options.missing')->build(),
100+
];
101+
}
102+
103+
$type = $scope->getType($args[1]->value);
104+
$optionsArrays = $type->getConstantArrays();
105+
106+
// Early return if $options parameter is not an array
107+
// (this is already handled by PHPStan's reflection provider)
108+
if ($optionsArrays === []) {
109+
return [];
110+
}
111+
112+
$allowedClassesChecked = false;
113+
$errors = [];
114+
115+
foreach ($optionsArrays as $optionsArray) {
116+
foreach ($optionsArray->getValueTypes() as $i => $valueType) {
117+
$key = $optionsArray->getKeyTypes()[$i]->getValue();
118+
119+
switch ($key) {
120+
case 'allowed_classes':
121+
$allowedClassesChecked = true;
122+
$errors = [
123+
...$errors,
124+
...$this->checkAllowedClasses($valueType),
125+
];
126+
break;
127+
case 'max_depth':
128+
if ($valueType->isInteger()->no()) {
129+
$errors[] = RuleErrorBuilder::message(sprintf(
130+
'Parameter #2 $options to function unserialize contains an invalid value %s for "max_depth".',
131+
$valueType->describe(VerbosityLevel::value()),
132+
))->identifier('unserialize.maxDepth.invalidType')->build();
133+
}
134+
break;
135+
default:
136+
$errors[] = RuleErrorBuilder::message(sprintf(
137+
'Parameter #2 $options to function unserialize contains unsupported option "%s".',
138+
$key,
139+
))->identifier('unserialize.unsupported')->build();
140+
}
141+
}
142+
}
143+
144+
if (!$allowedClassesChecked) {
145+
$errors[] = RuleErrorBuilder::message(
146+
'Parameter #2 $options to function unserialize must be present with "allowed_classes" set to false or a list of allowed class names.',
147+
)->identifier('unserialize.allowedClasses.missing')->build();
148+
}
149+
150+
return $errors;
151+
}
152+
153+
/**
154+
* @return list<RuleError>
155+
*/
156+
private function checkAllowedClasses(Type $valueType): array
157+
{
158+
// $options = false (disallow all classes)
159+
if ($valueType instanceof ConstantBooleanType && $valueType->getValue() === false) {
160+
return [];
161+
}
162+
163+
// $options = true (allow all classes)
164+
if ($valueType instanceof ConstantBooleanType && $valueType->getValue() === true) {
165+
return [
166+
RuleErrorBuilder::message(
167+
'Parameter #2 $options to function unserialize must either be false or a list of allowed class names.',
168+
)->identifier('unserialize.allowedClasses.insecure')->build(),
169+
];
170+
}
171+
172+
$allowedArrayType = new ArrayType(AllowedArrayKeysTypes::getType($this->phpVersion), new ClassStringType());
173+
$allowedType = TypeCombinator::union(new BooleanType(), $allowedArrayType);
174+
175+
// Invalid value type
176+
if (!$allowedType->accepts($valueType, true)->yes()) {
177+
return [
178+
RuleErrorBuilder::message(sprintf(
179+
'Parameter #2 $options to function unserialize contains an invalid value %s for "allowed_classes".',
180+
$valueType->describe(VerbosityLevel::value()),
181+
))->identifier('unserialize.allowedClasses.invalidType')->build(),
182+
];
183+
}
184+
185+
$errors = [];
186+
187+
foreach ($valueType->getConstantArrays() as $optionConstantArray) {
188+
foreach ($optionConstantArray->getValueTypes() as $j => $itemType) {
189+
// Unsupported value type
190+
if (!$itemType->isString()->yes()) {
191+
$errors[] = RuleErrorBuilder::message(sprintf(
192+
'Parameter #2 $options to function unserialize contains an invalid value for "allowed_classes" item #%d.',
193+
$j + 1,
194+
))->identifier('unserialize.allowedClasses.invalidType')->build();
195+
}
196+
}
197+
}
198+
199+
return $errors;
200+
}
201+
}

‎typo3/sysext/core/Classes/Package/Cache/PackageCacheEntry.php‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use TYPO3\CMS\Core\Package\Exception\PackageManagerCacheUnavailableException;
2121
use TYPO3\CMS\Core\Package\Exception\PackageStatesUnavailableException;
2222
use TYPO3\CMS\Core\Package\PackageInterface;
23+
use TYPO3\CMS\Core\Package\PackageManager;
2324
use TYPO3\CMS\Core\Serializer\DeserializationService;
2425

2526
/**
@@ -28,28 +29,46 @@
2829
* It interfaces between PackageManager and PackageCacheInterface.
2930
*
3031
* @internal
32+
*
33+
* @phpstan-import-type PackageKey from PackageManager
34+
* @phpstan-import-type PackageName from PackageManager
35+
* @phpstan-import-type StatesConfiguration from PackageManager
36+
* @phpstan-type CacheArray array{
37+
* identifier: string|null,
38+
* packageStatesConfiguration: StatesConfiguration,
39+
* packageAliasMap: array<PackageName, PackageKey>,
40+
* composerNameToPackageKeyMap: array<PackageName, PackageKey>,
41+
* packageObjects: string,
42+
* packageClasses: list<class-string>,
43+
* }
3144
*/
3245
class PackageCacheEntry
3346
{
3447
/**
3548
* Package configuration. Used by the PackageManager to identify "active" packages.
3649
* Every key in this array represents an active extension.
50+
*
51+
* @var StatesConfiguration
3752
*/
3853
private array $configuration;
3954

4055
/**
4156
* Alternative names for packages mapping to the package key.
4257
* Typically filled from replace section in composer.json
58+
*
59+
* @var array<PackageName, PackageKey>
4360
*/
4461
private array $aliasMap;
4562

4663
/**
4764
* Map from composer name of a package (key in this array) to its package key (value)
65+
*
66+
* @var array<PackageName, PackageKey>
4867
*/
4968
private array $composerNameMap;
5069

5170
/**
52-
* @var PackageInterface[]
71+
* @var array<PackageKey, PackageInterface>
5372
*/
5473
private array $packages;
5574

@@ -61,6 +80,12 @@ class PackageCacheEntry
6180
*/
6281
private ?string $identifier = null;
6382

83+
/**
84+
* @param StatesConfiguration $configuration
85+
* @param array<PackageName, PackageKey> $aliasMap
86+
* @param array<PackageName, PackageKey> $composerNameMap
87+
* @param array<PackageKey, PackageInterface> $packages
88+
*/
6489
private function __construct(
6590
array $configuration,
6691
array $aliasMap,
@@ -85,6 +110,12 @@ public static function ensureValidPackageConfiguration(array $configuration): vo
85110
}
86111
}
87112

113+
/**
114+
* @param StatesConfiguration $packageStatesConfiguration
115+
* @param array<PackageName, PackageKey> $packageAliasMap
116+
* @param array<PackageName, PackageKey> $composerNameToPackageKeyMap
117+
* @param array<PackageKey, PackageInterface> $packageObjects
118+
*/
88119
public static function fromPackageData(
89120
array $packageStatesConfiguration,
90121
array $packageAliasMap,
@@ -101,6 +132,9 @@ public static function fromPackageData(
101132
);
102133
}
103134

135+
/**
136+
* @param CacheArray $packageData
137+
*/
104138
public static function fromCache(array $packageData): self
105139
{
106140
try {
@@ -161,23 +195,32 @@ public function withIdentifier(string $identifier): self
161195
return $newEntry;
162196
}
163197

198+
/**
199+
* @return StatesConfiguration
200+
*/
164201
public function getConfiguration(): array
165202
{
166203
return $this->configuration;
167204
}
168205

206+
/**
207+
* @return array<PackageName, PackageKey>
208+
*/
169209
public function getAliasMap(): array
170210
{
171211
return $this->aliasMap;
172212
}
173213

214+
/**
215+
* @return array<PackageName, PackageKey>
216+
*/
174217
public function getComposerNameMap(): array
175218
{
176219
return $this->composerNameMap;
177220
}
178221

179222
/**
180-
* @return PackageInterface[]
223+
* @return array<PackageKey, PackageInterface>
181224
*/
182225
public function getPackages(): array
183226
{

‎typo3/sysext/core/Classes/Package/PackageManager.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
* @phpstan-type PackageName non-empty-string
5050
* @phpstan-type PackageConstraints array{dependencies: list<PackageKey>, suggestions: list<PackageKey>}
5151
* @phpstan-type StateConfiguration array{packagePath?: non-empty-string}
52+
* @phpstan-type StatesConfiguration array{packages?: array<PackageKey, StateConfiguration>, version?: int}
5253
*/
5354
class PackageManager implements SingletonInterface
5455
{
@@ -109,7 +110,7 @@ class PackageManager implements SingletonInterface
109110

110111
/**
111112
* Package states configuration as stored in the PackageStates.php file
112-
* @var array{packages?: array<PackageKey, StateConfiguration>, version?: int}
113+
* @var StatesConfiguration
113114
*/
114115
protected $packageStatesConfiguration = [];
115116

‎typo3/sysext/core/Classes/Serializer/AuthenticatedMessageDeserializer.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public function deserialize(string $payload, string $additionalSecret): mixed
6767
);
6868
}
6969
// explicitly allowing all classes here after successful HMAC validation
70+
/* @phpstan-ignore unserialize.allowedClasses.insecure (Integrity check already happens via HMAC validation) */
7071
return unserialize($serialized, ['allowed_classes' => true]);
7172
}
7273
}

‎typo3/sysext/core/Tests/Functional/TypoScript/AST/AstBuilderInterfaceTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ public function modifiedValueUpdatesOriginalValueCommentAware(): void
113113
*/
114114
private static function assertRootNodeIsSerializable(RootNode $ast): void
115115
{
116+
/* @phpstan-ignore unserialize.allowedClasses.insecure (Serialization within testing context does no harm) */
116117
self::assertEquals($ast, unserialize(serialize($ast), ['allowed_classes' => true]));
117118
}
118119
}

‎typo3/sysext/core/Tests/Functional/TypoScript/IncludeTree/SysTemplateTreeBuilderTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ private function getAst(RootInclude $rootInclude): RootNode
335335
*/
336336
private static function assertRootNodeIsSerializable(RootNode $ast): void
337337
{
338+
/* @phpstan-ignore unserialize.allowedClasses.insecure (Serialization within testing context does no harm) */
338339
self::assertEquals($ast, unserialize(serialize($ast), ['allowed_classes' => true]));
339340
}
340341
}

‎typo3/sysext/core/Tests/Unit/TypoScript/AST/AstBuilderInterfaceTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3204,6 +3204,7 @@ public function buildWithCommentsSetsPreviousValue(): void
32043204
*/
32053205
private static function assertRootNodeIsSerializable(RootNode $expectedAst, RootNode $ast): void
32063206
{
3207+
/* @phpstan-ignore unserialize.allowedClasses.insecure (Serialization within testing context does no harm) */
32073208
self::assertEquals($expectedAst, unserialize(serialize($ast), ['allowed_classes' => true]));
32083209
}
32093210
}

‎typo3/sysext/extbase/Classes/Mvc/Controller/ActionController.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,7 @@ protected function forwardToReferringRequest(): ?ResponseInterface
608608
);
609609
$arguments = [];
610610
if (is_string($referringRequestArguments['arguments'] ?? null)) {
611+
/* @phpstan-ignore unserialize.allowedClasses.insecure (Integrity check already happens via HMAC validation) */
611612
$arguments = unserialize(
612613
base64_decode($this->hashService->validateAndStripHmac(
613614
$referringRequestArguments['arguments'],

0 commit comments

Comments
 (0)