Skip to content

Commit 82c0c89

Browse files
authored
render callback rule (#217)
Process render callbacks to ensure they care callable and trusted. For #197
1 parent b2d2ba6 commit 82c0c89

File tree

8 files changed

+365
-0
lines changed

8 files changed

+365
-0
lines changed

extension.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,7 @@ services:
329329
-
330330
class: mglaman\PHPStanDrupal\Rules\Deprecations\StaticServiceDeprecatedServiceRule
331331
tags: [phpstan.rules.rule]
332+
333+
-
334+
class: mglaman\PHPStanDrupal\Rules\Drupal\RenderCallbackRule
335+
tags: [phpstan.rules.rule]
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace mglaman\PHPStanDrupal\Rules\Drupal;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Broker\Broker;
8+
use PHPStan\Reflection\Php\PhpFunctionReflection;
9+
use PHPStan\Reflection\ReflectionProvider;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use PHPStan\TrinaryLogic;
13+
use PHPStan\Type\ClosureType;
14+
use PHPStan\Type\Constant\ConstantArrayType;
15+
use PHPStan\Type\Constant\ConstantArrayTypeAndMethod;
16+
use PHPStan\Type\Constant\ConstantIntegerType;
17+
use PHPStan\Type\Constant\ConstantStringType;
18+
use PHPStan\Type\ObjectType;
19+
use PHPStan\Type\VerbosityLevel;
20+
21+
final class RenderCallbackRule implements Rule
22+
{
23+
24+
protected ReflectionProvider $reflectionProvider;
25+
26+
public function __construct(ReflectionProvider $reflectionProvider)
27+
{
28+
$this->reflectionProvider = $reflectionProvider;
29+
}
30+
31+
public function getNodeType(): string
32+
{
33+
return Node\Expr\ArrayItem::class;
34+
}
35+
36+
public function processNode(Node $node, Scope $scope): array
37+
{
38+
assert($node instanceof Node\Expr\ArrayItem);
39+
$key = $node->key;
40+
if (!$key instanceof Node\Scalar\String_) {
41+
return [];
42+
}
43+
// @see https://www.drupal.org/node/2966725
44+
$keysToCheck = ['#pre_render', '#post_render', '#lazy_builder', '#access_callback'];
45+
$keySearch = array_search($key->value, $keysToCheck, true);
46+
if ($keySearch === false) {
47+
return [];
48+
}
49+
$keyChecked = $keysToCheck[$keySearch];
50+
51+
$value = $node->value;
52+
if (!$value instanceof Node\Expr\Array_) {
53+
return [
54+
RuleErrorBuilder::message(sprintf('The "%s" render array value expects an array of callbacks.', $keyChecked))
55+
->line($node->getLine())->build()
56+
];
57+
}
58+
if (count($value->items) === 0) {
59+
return [];
60+
}
61+
62+
$trustedCallbackType = new ObjectType('Drupal\Core\Security\TrustedCallbackInterface');
63+
$errors = [];
64+
foreach ($value->items as $pos => $item) {
65+
if (!$item instanceof Node\Expr\ArrayItem) {
66+
continue;
67+
}
68+
$errorLine = $item->value->getLine();
69+
$type = $scope->getType($item->value);
70+
71+
if ($type instanceof ConstantStringType) {
72+
if (!$type->isCallable()->yes()) {
73+
$errors[] = RuleErrorBuilder::message(
74+
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos)
75+
)->line($errorLine)->build();
76+
continue;
77+
}
78+
// We can determine if the callback is callable through the type system. However, we cannot determine
79+
// if it is just a function or a static class call (MyClass::staticFunc).
80+
if ($this->reflectionProvider->hasFunction(new \PhpParser\Node\Name($type->getValue()), null)) {
81+
$errors[] = RuleErrorBuilder::message(
82+
sprintf("%s callback %s at key '%s' is not trusted.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos)
83+
)->line($errorLine)
84+
->tip('Change record: https://www.drupal.org/node/2966725.')
85+
->build();
86+
continue;
87+
}
88+
// @see \PHPStan\Type\Constant\ConstantStringType::isCallable
89+
preg_match('#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\\z#', $type->getValue(), $matches);
90+
if ($matches === null) {
91+
throw new \PHPStan\ShouldNotHappenException('Unable to get class name from ConstantStringType value: ' . $type->describe(VerbosityLevel::value()));
92+
}
93+
if (!$trustedCallbackType->isSuperTypeOf(new ObjectType($matches[1]))->yes()) {
94+
$errors[] = RuleErrorBuilder::message(
95+
sprintf("%s callback class '%s' at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.", $keyChecked, (new ObjectType($matches[1]))->describe(VerbosityLevel::value()), $pos)
96+
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
97+
}
98+
} elseif ($type instanceof ConstantArrayType) {
99+
if (!$type->isCallable()->yes()) {
100+
$errors[] = RuleErrorBuilder::message(
101+
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos)
102+
)->line($errorLine)->build();
103+
continue;
104+
}
105+
$typeAndMethodName = $type->findTypeAndMethodName();
106+
if ($typeAndMethodName === null) {
107+
throw new \PHPStan\ShouldNotHappenException();
108+
}
109+
110+
if (!$trustedCallbackType->isSuperTypeOf($typeAndMethodName->getType())->yes()) {
111+
$errors[] = RuleErrorBuilder::message(
112+
sprintf("%s callback class '%s' at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.", $keyChecked, $typeAndMethodName->getType()->describe(VerbosityLevel::value()), $pos)
113+
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
114+
}
115+
} elseif ($type instanceof ClosureType) {
116+
if ($scope->isInClass()) {
117+
$classReflection = $scope->getClassReflection();
118+
if ($classReflection === null) {
119+
throw new \PHPStan\ShouldNotHappenException();
120+
}
121+
$classType = new ObjectType($classReflection->getName());
122+
$formType = new ObjectType('\Drupal\Core\Form\FormInterface');
123+
if ($formType->isSuperTypeOf($classType)->yes()) {
124+
$errors[] = RuleErrorBuilder::message(
125+
sprintf("%s may not contain a closure at key '%s' as forms may be serialized and serialization of closures is not allowed.", $keyChecked, $pos)
126+
)->line($errorLine)->build();
127+
}
128+
}
129+
} else {
130+
$errors[] = RuleErrorBuilder::message(
131+
sprintf("%s value '%s' at key '%s' is invalid.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos)
132+
)->line($errorLine)->build();
133+
}
134+
}
135+
136+
return $errors;
137+
}
138+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: phpstan_fixtures
2+
type: module
3+
core: 8.x
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php declare(strict_types=1);
2+
3+
use Drupal\Core\Url;
4+
5+
function sample_pre_render_callback(array $el): array {
6+
return $el;
7+
}
8+
9+
function pre_render_callback_rule_alter_some_stuff() {
10+
$sample1 = [
11+
'#type' => 'link',
12+
'#url' => Url::fromRoute('<front>'),
13+
'#title' => 'FooBar',
14+
'#pre_render' => [null],
15+
];
16+
17+
$sample2 = [
18+
'#pre_render' => '',
19+
];
20+
$sample3 = [
21+
'#pre_render' => null,
22+
];
23+
24+
$sample4 = [
25+
'#pre_render' => [
26+
'invalid_func',
27+
'sample_pre_render_callback',
28+
'\Drupal\pre_render_callback_rule\RenderArrayWithPreRenderCallback::preRenderCallback',
29+
'\Drupal\pre_render_callback_rule\NotTrustedCallback::unsafeCallback',
30+
['\Drupal\pre_render_callback_rule\NotTrustedCallback', 'unsafeCallback']
31+
],
32+
];
33+
34+
$sample5 = [
35+
'#type' => 'link',
36+
'#url' => Url::fromRoute('<front>'),
37+
'#title' => 'FooBar',
38+
'#pre_render' => [
39+
static function(array $element): array {
40+
return $element;
41+
}
42+
],
43+
];
44+
45+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Drupal\pre_render_callback_rule;
4+
5+
use Drupal\Core\Form\FormInterface;
6+
use Drupal\Core\Form\FormStateInterface;
7+
use Drupal\Core\Security\TrustedCallbackInterface;
8+
use Drupal\Core\Url;
9+
10+
final class FormWithClosure implements FormInterface, TrustedCallbackInterface {
11+
12+
public static function preRenderCallback(array $element) {
13+
return $element;
14+
}
15+
16+
public static function trustedCallbacks()
17+
{
18+
return ['preRenderCallback'];
19+
}
20+
21+
public function getFormId()
22+
{
23+
// TODO: Implement getFormId() method.
24+
}
25+
26+
public function buildForm(array $form, FormStateInterface $form_state)
27+
{
28+
return [
29+
'#type' => 'link',
30+
'#url' => Url::fromRoute('<front>'),
31+
'#title' => 'FooBar',
32+
'#pre_render' => [
33+
[self::class, 'preRenderCallback'],
34+
[$this, 'preRenderCallback'],
35+
static function(array $element): array {
36+
return $element;
37+
}
38+
]
39+
];
40+
}
41+
42+
public function validateForm(array &$form, FormStateInterface $form_state)
43+
{
44+
// TODO: Implement validateForm() method.
45+
}
46+
47+
public function submitForm(array &$form, FormStateInterface $form_state)
48+
{
49+
// TODO: Implement submitForm() method.
50+
}
51+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Drupal\pre_render_callback_rule;
4+
5+
final class NotTrustedCallback {
6+
7+
public static function unsafeCallback(array $element): array {
8+
return $element;
9+
}
10+
11+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Drupal\pre_render_callback_rule;
4+
5+
use Drupal\Core\Security\TrustedCallbackInterface;
6+
use Drupal\Core\Url;
7+
8+
final class RenderArrayWithPreRenderCallback implements TrustedCallbackInterface {
9+
10+
public function staticCallback(): array {
11+
return [
12+
'#type' => 'link',
13+
'#url' => Url::fromRoute('<front>'),
14+
'#title' => 'FooBar',
15+
'#pre_render' => [
16+
[self::class, 'preRenderCallback'],
17+
[$this, 'preRenderCallback'],
18+
static function(array $element): array {
19+
return $element;
20+
}
21+
]
22+
];
23+
}
24+
25+
public static function preRenderCallback(array $element) {
26+
return $element;
27+
}
28+
29+
public static function trustedCallbacks()
30+
{
31+
return ['preRenderCallback'];
32+
}
33+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace mglaman\PHPStanDrupal\Tests\Rules;
4+
5+
6+
use mglaman\PHPStanDrupal\Tests\DrupalRuleTestCase;
7+
use mglaman\PHPStanDrupal\Rules\Drupal\RenderCallbackRule;
8+
9+
final class PreRenderCallbackRuleTest extends DrupalRuleTestCase {
10+
11+
protected function getRule(): \PHPStan\Rules\Rule
12+
{
13+
return new RenderCallbackRule(
14+
$this->createReflectionProvider()
15+
);
16+
}
17+
18+
/**
19+
* @dataProvider fileData
20+
*/
21+
public function testRule(string $path, array $errorMessages): void
22+
{
23+
$this->analyse([$path], $errorMessages);
24+
}
25+
26+
public function fileData(): \Generator
27+
{
28+
yield [
29+
__DIR__ . '/../../fixtures/drupal/modules/pre_render_callback_rule/pre_render_callback_rule.module',
30+
[
31+
[
32+
"#pre_render value 'null' at key '0' is invalid.",
33+
14
34+
],
35+
[
36+
'The "#pre_render" render array value expects an array of callbacks.',
37+
18
38+
],
39+
[
40+
'The "#pre_render" render array value expects an array of callbacks.',
41+
21
42+
],
43+
[
44+
"#pre_render callback 'invalid_func' at key '0' is not callable.",
45+
26
46+
],
47+
[
48+
"#pre_render callback 'sample_pre_render…' at key '1' is not trusted.",
49+
27,
50+
'Change record: https://www.drupal.org/node/2966725.',
51+
],
52+
[
53+
"#pre_render callback class 'Drupal\pre_render_callback_rule\NotTrustedCallback' at key '3' does not implement Drupal\Core\Security\TrustedCallbackInterface.",
54+
29,
55+
'Change record: https://www.drupal.org/node/2966725.',
56+
],
57+
[
58+
"#pre_render callback class 'Drupal\pre_render_callback_rule\NotTrustedCallback' at key '4' does not implement Drupal\Core\Security\TrustedCallbackInterface.",
59+
30,
60+
'Change record: https://www.drupal.org/node/2966725.',
61+
],
62+
]
63+
];
64+
yield [
65+
__DIR__ . '/../../fixtures/drupal/modules/pre_render_callback_rule/src/RenderArrayWithPreRenderCallback.php',
66+
[]
67+
];
68+
yield [
69+
__DIR__ . '/../../fixtures/drupal/modules/pre_render_callback_rule/src/FormWithClosure.php',
70+
[
71+
[
72+
"#pre_render may not contain a closure at key '2' as forms may be serialized and serialization of closures is not allowed.",
73+
35
74+
]
75+
]
76+
];
77+
}
78+
79+
80+
}

0 commit comments

Comments
 (0)