Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bd70371
HPB-4006 add `FormRequest` from vendor Laravel
Treggats Oct 3, 2024
aa4c010
HPB-4005 add stubs
Treggats Oct 3, 2024
4fdca15
HPB-4006 add test for using unvalidated data outside of a request class
Treggats Oct 3, 2024
c5f6722
HPB-4005 implement the rule
Treggats Oct 3, 2024
b6524ea
HPB-4006 add a controller class stub
Treggats Oct 4, 2024
ab806f2
HPB-4005 simplify checking the blacklist
Treggats Oct 3, 2024
a576dd9
HPB-4006 also check sub namespace
Treggats Oct 11, 2024
c544a03
HPB-4006 update minimum version PHPStan (1.11.0)
Treggats Oct 14, 2024
c7df77f
HPB-4006 simplify expected error message
Treggats Oct 14, 2024
acdb7a6
HPB-4006 update test name and separate regular request and form request
Treggats Oct 14, 2024
dc911f1
HPB-4006 update method names to better reflect its purpose
Treggats Oct 14, 2024
b5f0922
HPB-4006 merge illuminate & form request check
Treggats Oct 15, 2024
b7c590d
HPB-4006 check for chained allowed methods
Treggats Oct 15, 2024
09dbde9
HPB-4006 move to Validation namespace
Treggats Oct 15, 2024
368fccf
HPB-4006 introduce base class
Treggats Oct 15, 2024
082351d
HPB-4006 split form request and illuminate request rules
Treggats Oct 15, 2024
da7ca65
HPB-4006 update name for the illuminate request rule
Treggats Oct 15, 2024
7877685
HPB-4006 update name for the form request rule
Treggats Oct 15, 2024
38ceef0
HPB-4006 update name blacklist check
Treggats Oct 15, 2024
51b6479
HPB-4006 move name helper to the base class
Treggats Oct 15, 2024
3bbde4f
HPB-4006 remove redundant guard clause
Treggats Oct 15, 2024
4f76367
HPB-4006 remove redundant assertion
Treggats Oct 15, 2024
efe3ae8
HPB-4006 remove outdated class
Treggats Oct 15, 2024
b72726a
HPB-4006 add support for the "validated()" method
Treggats Oct 15, 2024
eac95d2
HPB-4006 cs & dataset update
Treggats Oct 15, 2024
96aefae
HPB-4006 replace illuminate packages with laravel/framework
Treggats Oct 16, 2024
0817eee
HPB-4006 remove illuminate stub class
Treggats Oct 16, 2024
8c7f99a
HPB-4006 reorder composer packages
Treggats Oct 16, 2024
7e0efbc
HPB-4006 extract namespace check
Treggats Oct 29, 2024
4ec6942
HPB-4006 move allowed methods to a config
Treggats Oct 30, 2024
d989686
HPB-4006 simplify mapping test stubs to namespace
Treggats Oct 31, 2024
ae6d2d7
HPB-4006 remove the FormRequest rule
Treggats Oct 31, 2024
d15e50d
HPB-4006 rename rule
Treggats Oct 31, 2024
5464b76
HPB-4006 add "usesValidMethod" method to check for allowed methods
Treggats Oct 31, 2024
992249c
HPB-4006 update getting the node name
Treggats Oct 31, 2024
61b014a
HPB-4006 solidify retrieving method parameter classnames
Treggats Oct 31, 2024
370e174
HPB-4006 handle anonymous classes
Treggats Oct 31, 2024
fe37273
HPB-4006 add additional info to the tip
Treggats Oct 31, 2024
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
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"license": "MIT",
"autoload": {
"psr-4": {
"Hihaho\\PhpstanRules\\": "src/"
"Hihaho\\PhpstanRules\\": "src/",
"Illuminate\\Foundation\\Http\\": "tests/stubs/Illuminate/Foundation/Http",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map stubs to the stubbed namespace

"App\\Http\\Controllers\\": "tests/stubs/App/Http/Controllers",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapping namespaces to the stub file location

"App\\Http\\Requests\\": "tests/stubs/App/Http/Requests"
}
},
"autoload-dev": {
Expand All @@ -23,7 +26,7 @@
"minimum-stability": "stable",
"require": {
"php": "^8.2",
"phpstan/phpstan": "^1.10.55",
"phpstan/phpstan": "^1.11.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first version where identifiers are used

"illuminate/support": "^10|^11",
"laravel/pint": "^1.13.9"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the PHPStan and Laravel Pint packages are used during development. So made more sense to have them in the require-dev

Copy link
Member

@RobertBoes RobertBoes Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"illuminate/support": "^10|^11", would need to stay, as without it you'd be able to install the package in Laravel < 10 (or even, not in a Laravel app)
Could also change it for illuminate/contracts, not sure what the best one would be

},
Expand Down
8 changes: 8 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
services:
-
class: Hihaho\PhpstanRules\Rules\Validation\ScopeIlluminateRequestValidateMethods
tags:
- phpstan.rules.rule
-
class: Hihaho\PhpstanRules\Rules\Validation\ScopeFormRequestValidateMethods
tags:
- phpstan.rules.rule
-
class: Hihaho\PhpstanRules\Rules\NoInvadeInAppCode
tags:
Expand Down
72 changes: 72 additions & 0 deletions src/Rules/Validation/ScopeFormRequestValidateMethods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php declare(strict_types=1);

namespace Hihaho\PhpstanRules\Rules\Validation;

use Illuminate\Foundation\Http\FormRequest;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
use ReflectionClass;
use ReflectionException;

final class ScopeFormRequestValidateMethods extends ScopeValidationMethods
{
public function getNodeType(): string
{
return MethodCall::class;
}

/**
* @phpstan-param MethodCall $node
* @throws ReflectionException
* @throws ShouldNotHappenException
*/
public function processNode(Node $node, Scope $scope): array
{
if (str_starts_with($scope->getNamespace(), 'App\\Http\\Request')) {
return [];
}

if (! $this->hasFormRequestClass($scope)) {
return [];
}

if ($this->usesValidMethod(varName: $this->nameFrom($node->var), methodName: $this->nameFrom($node))) {
return [];
}

return [
RuleErrorBuilder::message(
'Usage of unvalidated request data is not allowed outside of App\\Http\\Requests'
)
->nonIgnorable()
->tip('Use $request->safe() to use request data')
->identifier('hihaho.request.unsafeRequestData')
->build(),
];
}

/** @throws ReflectionException */
private function hasFormRequestClass(Scope $scope): bool
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this loops through the methods is the inspected class and checks if the arguments are an instance/extend from the FormRequest class

{
$parentClassName = static fn (ReflectionClass $fqn) => $fqn->getParentClass()->getName();

return collect($this->getClassMethods($scope))
->map(fn (array $method) => $this->getMethodParameterClassnames($method))
->flatten()
->map(fn (string $className) => new ReflectionClass($className))
->filter(fn (ReflectionClass $className) => $parentClassName($className) === FormRequest::class)
->isNotEmpty();
}

private function usesValidMethod(string $varName, string $methodName): bool
{
if ($varName === 'request' && ($methodName === 'safe' || $methodName === 'validated')) {
return true;
}

return $varName === 'safe' && $this->isValidateMethod($methodName);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a validation method chained of the safe() method is allowed

}
}
58 changes: 58 additions & 0 deletions src/Rules/Validation/ScopeIlluminateRequestValidateMethods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php declare(strict_types=1);

namespace Hihaho\PhpstanRules\Rules\Validation;

use Illuminate\Http\Request as IlluminateRequest;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
use ReflectionException;

final class ScopeIlluminateRequestValidateMethods extends ScopeValidationMethods
{
public function getNodeType(): string
{
return MethodCall::class;
}

/**
* @phpstan-param MethodCall $node
* @throws ShouldNotHappenException | ReflectionException
*/
public function processNode(Node $node, Scope $scope): array
{
if (str_starts_with($scope->getNamespace(), 'App\\Http\\Request')) {
return [];
}

if (! $this->hasIlluminateRequestClass($scope)) {
return [];
}

if (! $this->isValidateMethod($this->nameFrom($node))) {
return [];
}

return [
RuleErrorBuilder::message(
'Usage of unvalidated request data is not allowed outside of App\\Http\\Requests'
)
->nonIgnorable()
->tip('Use $request->safe() to use request data')
->identifier('hihaho.request.unsafeRequestData')
->build(),
];
}

/** @throws ReflectionException */
private function hasIlluminateRequestClass(Scope $scope): bool
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this check is a bit simpler than the FormRequest one. Reason is that we directly have a class we need to check. So no need for extra checks or conversions.

{
return collect($this->getClassMethods($scope))
->map(fn (array $method) => $this->getMethodParameterClassnames($method)
->filter(fn (string $fqn) => $fqn === IlluminateRequest::class)
)
->isNotEmpty();
}
}
91 changes: 91 additions & 0 deletions src/Rules/Validation/ScopeValidationMethods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php declare(strict_types=1);

namespace Hihaho\PhpstanRules\Rules\Validation;

use Illuminate\Support\Collection;
use Illuminate\Support\Stringable;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Type\ObjectType;
use ReflectionClass;
use ReflectionException;
use ReflectionIntersectionType;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionUnionType;

/**
* @implements \PHPStan\Rules\Rule<\PhpParser\Node\Expr\MethodCall>
*/
abstract class ScopeValidationMethods implements Rule
{
abstract public function getNodeType(): string;

abstract public function processNode(Node $node, Scope $scope): array;

/**
* @phpstan-return ReflectionMethod[]
* @throws ReflectionException
*/
protected function getClassMethods(Scope $scope): array
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the Scope object contains the PHP class that is being inspected. This method gathers all the methods that are present in that class

{
$type = new ObjectType(className: $scope->getClassReflection()?->getName(), classReflection: $scope->getClassReflection());
/** @var ReflectionMethod[] $methods */
$methods = array_map(static function (string $className): array {
return (new ReflectionClass($className))->getMethods();
}, $type->getObjectClassNames());

return $methods;
}

protected function getMethodParameterClassnames(array $method): Collection
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method receives a list of methods, for which it will get the parameters and return the type.

{
return collect($method)
->map(fn (ReflectionMethod $method) => $method->getParameters())
->map(fn (array $parameter) => array_map(static function (ReflectionParameter $parameter) {
/** @var ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|null $type */
$type = $parameter->getType();

return $type?->getName();
}, $parameter))
->flatten();
}

protected function nameFrom(MethodCall|Variable $var): string
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

helper to get the name from a Node property

{
if ($var->name instanceof Stringable) {
return $var->name->toString();
}

if ($var->name instanceof Identifier) {
return $var->name->toString();
}

return $var->name;
}

protected function isValidateMethod(string $methodName): bool
{
$methodNames = [
'collect',
'all',
'only',
'except',
'input',
'get',
'keys',
'string',
'str',
'integer',
'float',
'boolean',
];

return in_array($methodName, $methodNames, strict: true);
}
}
30 changes: 30 additions & 0 deletions tests/Rules/Validation/ScopeFormRequestValidateMethodsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php declare(strict_types=1);

namespace Rules\Validation;

use Hihaho\PhpstanRules\Rules\Validation\ScopeFormRequestValidateMethods;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\Test;

final class ScopeFormRequestValidateMethodsTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new ScopeFormRequestValidateMethods();
}

#[Test]
public function form_request_class_does_not_use_unvalidated_data_outside_its_namespace(): void
{
$this->analyse([__DIR__ . '/../../stubs/App/Http/Requests/UserRequest.php'], []);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usage of validation methods are allowed within the App\Http\Requests namespace


$this->analyse([__DIR__ . '/../../stubs/App/Http/Controllers/PetControllerStub.php'], [
[
'Usage of unvalidated request data is not allowed outside of App\\Http\\Requests',
14,
'Use $request->safe() to use request data',
],
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php declare(strict_types=1);

namespace Rules\Validation;

use Hihaho\PhpstanRules\Rules\Validation\ScopeIlluminateRequestValidateMethods;
use PHPStan\Analyser\Error;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\Test;

/**
* @extends RuleTestCase<ScopeIlluminateRequestValidateMethods>
*/
final class ScopeIlluminateRequestValidateMethodTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new ScopeIlluminateRequestValidateMethods();
}

#[Test]
public function illuminate_http_request_does_not_use_unvalidated_methods_outside_app_http_requests_namespace(): void
{
/** @var Error[] $errors */
$errors = $this->gatherAnalyserErrors([__DIR__ . '/../../stubs/App/Http/Controllers/PeopleControllerStub.php']);
$validErrors = array_filter(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are more errors present, but we only want to assert the errors from the rules we are introducing into this PR

$errors,
static fn (Error $error): bool => $error->getIdentifier() === 'hihaho.request.unsafeRequestData'
);
self::assertCount(6, $validErrors);

$unsafeRequestDataError = static fn (int $line) => [
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because of the amount of the repeated error messages, I've extracted it to a simple closure

'Usage of unvalidated request data is not allowed outside of App\\Http\\Requests',
$line,
'Use $request->safe() to use request data',
];

$this->analyse([__DIR__ . '/../../stubs/App/Http/Controllers/PeopleControllerStub.php'], [
$unsafeRequestDataError(13),
$unsafeRequestDataError(14),
$unsafeRequestDataError(15),
$unsafeRequestDataError(16),
$unsafeRequestDataError(18),
$unsafeRequestDataError(21),
[
'No error with identifier method.notFound is reported on line 20.',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this extra error is present because a @phpstan-ignore is used in the stub

20,
],
]);

}
}
41 changes: 41 additions & 0 deletions tests/stubs/App/Http/Controllers/PeopleControllerStub.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class PeopleControllerStub
{
public function __invoke(Request $request)
{
return new JsonResponse([
'first_name' => $request->input('first_name'),
'last_name' => $request->string('last_name'),
'age' => $request->integer('age'),
'has_children' => $request->boolean('has_children'),
'children' => [
'name' => $request->only(['name']),
],
'email' => $request->safe()->email, // @phpstan-ignore method.notFound
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the safe() method does not exist on the Illuminate Request class, it is allowed. So ignoring the method.notFound error, in favour of being able to assert against it.

'city' => $request->get('city'),
]);
}

public function show(Request $request): JsonResponse
{
$data = $request->validate([]);

return new JsonResponse([
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'age' => $data['age'],
'has_children' => $data['has_children'],
'children' => [
'name' => $data['name'],
],
'email' => $data['email'],
'city' => $data['city'],
]);
}
}
Loading