Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
4 changes: 4 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
services:
-
class: Hihaho\PhpstanRules\Rules\ScopeRequestValidateMethods
tags:
- phpstan.rules.rule
-
class: Hihaho\PhpstanRules\Rules\NoInvadeInAppCode
tags:
Expand Down
158 changes: 158 additions & 0 deletions src/Rules/ScopeRequestValidateMethods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php declare(strict_types=1);

namespace Hihaho\PhpstanRules\Rules;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request as IlluminateRequest;
use Illuminate\Support\Collection;
use Illuminate\Support\Stringable;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
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>
*/
final class ScopeRequestValidateMethods implements Rule
{
public function getNodeType(): string
{
return MethodCall::class;
}

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

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

if ($this->usesValidatedMethod($node) && $this->isBlacklistedMethod($node->name->toString())) {
return [];
}

if (! $this->isBlacklistedMethod($node->name->toString())) {
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 hasRequestClass(Scope $scope): bool
{
return $this->hasIlluminateRequestClass($scope) || $this->hasFormRequestClass($scope);
}

/** @throws ReflectionException */
private function hasIlluminateRequestClass(Scope $scope): bool
{
return collect($this->getClassMethods($scope))
->map(fn (array $method) => $this->getMethodParameterClassnames($method)
->filter(fn (string $fqn) => $fqn === IlluminateRequest::class)
)
->isNotEmpty();
}

/** @throws ReflectionException */
private function hasFormRequestClass(Scope $scope): bool
{
$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();
}

/**
* @phpstan-param MethodCall $node
*/
private function usesValidatedMethod(Node $node): bool
{
/** @var Node\Expr\Variable $var */
$var = $node->var;
if ($var->name instanceof Stringable) {
return $var->name->toString() === 'safe';
}

if ($var->name instanceof Node\Identifier) {
return $var->name->toString() === 'safe';
}

return $var->name === 'safe';
}

/**
* @phpstan-return ReflectionMethod[]
* @throws ReflectionException
*/
private function getClassMethods(Scope $scope): array
{
$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;
}

private function getMethodParameterClassnames(array $method): Collection
{
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();
}

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

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

namespace Rules;

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

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

#[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(
$errors,
static fn (Error $error): bool => $error->getIdentifier() === 'hihaho.request.unsafeRequestData'
);
self::assertCount(6, $validErrors);

$unsafeRequestDataError = static fn (int $line) => [
'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.',
20,
],
]);

}

#[Test]
public function form_request_class_does_not_use_unvalidated_data_outside_its_namespace(): void
{
/** @var Error[] $errors */
$errors = $this->gatherAnalyserErrors([__DIR__ . '/../stubs/App/Http/Requests/UserRequest.php']);
self::assertCount(0, $errors);
$this->analyse([__DIR__ . '/../stubs/App/Http/Requests/UserRequest.php'], []);

$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',
],
]);
}
}
39 changes: 39 additions & 0 deletions tests/Rules/stubs/RequestValidateMethodsInNamespaceStub.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\UserRequest;
use Illuminate\Http\Request;

final class RequestValidateMethodsInNamespaceStub
{
public function __invoke(Request $request)
{
return response()->json([
'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,
'city' => $request->get('city'),
]);
}

public function show(UserRequest $request)
{
return response()->json([
'first_name' => $request->safe()->string('first_name'),
'last_name' => $request->safe()->string('last_name'),
'age' => $request->safe()->integer('age'),
'has_children' => $request->safe()->boolean('has_children'),
'children' => [
'name' => $request->safe()->only(['name']),
],
'email' => $request->safe()->string('email'),
'city' => $request->safe()->string('city'),
]);
}
}
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'],
]);
}
}
19 changes: 19 additions & 0 deletions tests/stubs/App/Http/Controllers/PetControllerStub.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\UserRequest;
use Illuminate\Http\JsonResponse;

final class PetControllerStub
{
public function __invoke(UserRequest $request)
{
return new JsonResponse([
'data' => [
'name' => $request->get('name'),
'breed' => $request->safe()->str('breed'),
],
]);
}
}
33 changes: 33 additions & 0 deletions tests/stubs/App/Http/Requests/UserRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class UserRequest extends FormRequest
{
public function name(): string
{
return sprintf(
'%s %s',
$this->string('first_name'),
$this->string('last_name')
);
}

public function email(): string
{
return $this->input('email');
}

public function hasChildren(): bool
{
return $this->boolean('children');
}

public function childrenNames(): array
{
return $this->get('children')
->only('name');
}
}
Loading