Skip to content

Commit 91be84c

Browse files
authored
Add Security tools : Strict Cookies, Headers, and PHPStan custom rule (#101)
* Add SameSite Cookie attribute * Add security headers * Add PHP Stan rule for Route Security checking
1 parent fd92b38 commit 91be84c

File tree

14 files changed

+1062
-421
lines changed

14 files changed

+1062
-421
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
docker-compose.override.yml
22
/.env
3-
.ssh/
3+
.ssh/
4+
.idea/

apps/back/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"doctrine/doctrine-bundle": "^2.7",
1212
"doctrine/doctrine-migrations-bundle": "^3.2",
1313
"doctrine/orm": "^2.14",
14+
"nelmio/security-bundle": "^3.0",
1415
"onelogin/php-saml": "^4.1",
1516
"phpdocumentor/reflection-docblock": "^5.3",
1617
"phpstan/phpdoc-parser": "^1.13",

apps/back/composer.lock

Lines changed: 645 additions & 414 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/back/config/bundles.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@
1010
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
1111
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
1212
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
13+
Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true],
1314
];
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# config/packages/nelmio_security.yaml
2+
nelmio_security:
3+
# signs/verifies all cookies
4+
# FIXME :
5+
# - PHPSESSID cookie is not correctly passed into the signing process, so we cannot use '*'. But additional cookies could be set a signed here
6+
# - see https://github.com/nelmio/NelmioSecurityBundle/issues/154
7+
signed_cookie:
8+
names: []
9+
# prevents framing of the entire site
10+
clickjacking:
11+
paths:
12+
'^/.*': DENY
13+
hosts:
14+
# - '^foo\.com$'
15+
# - '\.example\.org$'
16+
17+
# prevents redirections outside the website's domain
18+
external_redirects:
19+
abort: true
20+
log: true
21+
22+
# prevents inline scripts, unsafe eval, external scripts/images/styles/frames, etc
23+
csp:
24+
hosts: []
25+
content_types: []
26+
enforce:
27+
level1_fallback: false
28+
browser_adaptive:
29+
enabled: false
30+
# report-uri: '%router.request_context.base_url%/nelmio/csp/report'
31+
default-src:
32+
- 'none'
33+
script-src:
34+
- 'self'
35+
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
36+
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
37+
38+
# disables content type sniffing for script resources
39+
content_type:
40+
nosniff: true
41+
42+
# forces Microsoft's XSS-Protection with
43+
# its block mode
44+
xss_protection:
45+
enabled: true
46+
mode_block: true
47+
# report_uri: '%router.request_context.base_url%/nelmio/xss/report'
48+
49+
# Send a full URL in the ``Referer`` header when performing a same-origin request,
50+
# only send the origin of the document to secure destination (HTTPS->HTTPS),
51+
# and send no header to a less secure destination (HTTPS->HTTP).
52+
# If ``strict-origin-when-cross-origin`` is not supported, use ``no-referrer`` policy,
53+
# no referrer information is sent along with requests.
54+
referrer_policy:
55+
enabled: true
56+
policies:
57+
- 'no-referrer'
58+
- 'strict-origin-when-cross-origin'
59+
60+
# forces HTTPS handling, don't combine with flexible mode
61+
# and make sure you have SSL working on your site before enabling this
62+
# forced_ssl:
63+
# hsts_max_age: 2592000 # 30 days
64+
# hsts_subdomains: true
65+
# redirect_status_code: 302 # default, switch to 301 for permanent redirects
66+
67+
# flexible HTTPS handling, read the detailed config info
68+
# and make sure you have SSL working on your site before enabling this
69+
70+
71+
when@prod:
72+
nelmio_security:
73+
# depends if you have a reverse proxy that will handle HTTPS, uncomment if you deploy with ansible for instance
74+
# forced_ssl:
75+
# hsts_max_age: 2592000 # 30 days
76+
# hsts_subdomains: true
77+
# redirect_status_code: 302 # default, switch to 301 for permanent redirects
78+
79+
# Seems unnecessary because we are'nt using any insecure page in prod
80+
# flexible_ssl:
81+
# cookie_name: auth
82+
# unsecured_logout: false

apps/back/phpstan.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ includes:
2121
- vendor/phpstan/phpstan-symfony/extension.neon
2222
- vendor/phpstan/phpstan-symfony/rules.neon
2323

24+
rules:
25+
- App\DevTools\PHPStan\RouteSecurityChecker
26+
2427
## May have to add static forbidden

apps/back/src/Controller/UserController.php

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@
88
use App\Dto\Request\UpdateUserDto;
99
use App\Entity\User;
1010
use App\Repository\UserRepository;
11+
use App\Security\Enum\Right;
12+
use App\Security\Voter\UserVoter;
1113
use App\UseCase\User\CreateUser;
1214
use App\UseCase\User\UpdateUser;
1315
use Doctrine\ORM\EntityManagerInterface;
16+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1417
use Symfony\Component\HttpFoundation\JsonResponse;
1518
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
1619
use Symfony\Component\Routing\Annotation\Route;
20+
use Symfony\Component\Security\Core\Security;
1721
use Symfony\Component\Security\Http\Attribute\IsGranted;
1822

19-
class UserController
23+
class UserController extends AbstractController
2024
{
2125
public function __construct(
2226
private readonly EntityManagerInterface $entityManager,
@@ -29,6 +33,7 @@ public function __construct(
2933
#[Route('/users', name: 'create_user', methods: ['POST'])]
3034
public function createUser(#[MapRequestPayload] CreateUserDto $userDto): JsonResponse
3135
{
36+
$this->denyAccessUnlessGranted(UserVoter::CREATE_USER);
3237
$user = $this->createUser->createUser($userDto);
3338

3439
$this->entityManager->flush();
@@ -37,16 +42,17 @@ public function createUser(#[MapRequestPayload] CreateUserDto $userDto): JsonRes
3742
}
3843

3944
#[Route('/users', name: 'list_users', methods: ['GET'])]
40-
public function listUsers(): JsonResponse
45+
#[IsGranted(UserVoter::VIEW_ANY_USER)]
46+
public function listUsers(\Symfony\Bundle\SecurityBundle\Security $security): JsonResponse
4147
{
4248
$users = $this->userRepository->findAll();
4349

4450
return new JsonResponse($users);
4551
}
4652

4753
#[Route('/users/{id}', name: 'get_user', methods: ['GET'])]
48-
#[IsGranted('ROLE_RIGHT_USER_READ')]
49-
public function getUser(User $user): JsonResponse
54+
#[IsGranted(UserVoter::VIEW_ANY_USER)]
55+
public function getUserEntity(User $user): JsonResponse
5056
{
5157
return new JsonResponse([
5258
'id' => $user->getId(),
@@ -55,7 +61,7 @@ public function getUser(User $user): JsonResponse
5561
}
5662

5763
#[Route('/users/{id}', name: 'update_user', methods: ['PUT'])]
58-
#[IsGranted('ROLE_RIGHT_USER_UPDATE')]
64+
#[IsGranted(UserVoter::EDIT_ANY_USER, subject: 'user')]
5965
public function updateUser(User $user, #[MapRequestPayload] UpdateUserDto $userDto): JsonResponse
6066
{
6167
$user = $this->updateUser->updateUser($user, $userDto);
@@ -69,7 +75,7 @@ public function updateUser(User $user, #[MapRequestPayload] UpdateUserDto $userD
6975
}
7076

7177
#[Route('/users/{id}', name: 'delete_user', methods: ['DELETE'])]
72-
#[IsGranted('ROLE_RIGHT_USER_DELETE')]
78+
#[IsGranted(UserVoter::DELETE_ANY_USER, subject: 'user')]
7379
public function deleteUser(User $user): JsonResponse
7480
{
7581
$this->entityManager->remove($user);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\DevTools\PHPStan;
6+
7+
use Attribute;
8+
9+
/**
10+
* Add this attribute to Routes that can be accessed by ANY ONE
11+
* This is generally the case for
12+
* - public website / content
13+
* - Login / Password recovery
14+
* - If you are securing your route elsewhere (firewall, security.yml, custom function, etc.)
15+
*/
16+
#[Attribute(Attribute::TARGET_METHOD)]
17+
class IKnowWhatImDoingThisIsAPublicRoute
18+
{
19+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\DevTools\PHPStan;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\MethodCall;
9+
use PhpParser\NodeTraverser;
10+
use PhpParser\NodeVisitorAbstract;
11+
use PHPStan\Analyser\Scope;
12+
use PHPStan\Node\InClassMethodNode;
13+
use PHPStan\Rules\Rule;
14+
use PHPStan\Rules\RuleError;
15+
use PHPStan\Rules\RuleErrorBuilder;
16+
use ReflectionAttribute;
17+
use ReflectionException;
18+
use ReflectionMethod;
19+
use Symfony\Component\Routing\Annotation\Route;
20+
use Symfony\Component\Security\Http\Attribute\IsGranted;
21+
22+
use function array_filter;
23+
use function array_values;
24+
25+
/** @implements Rule<InClassMethodNode> */
26+
class RouteSecurityChecker implements Rule
27+
{
28+
public function getNodeType(): string
29+
{
30+
return InClassMethodNode::class;
31+
}
32+
33+
/** @inheritdoc */
34+
public function processNode(Node $node, Scope $scope): array
35+
{
36+
\assert($node instanceof InClassMethodNode);
37+
$className = $scope->getClassReflection()?->getName();
38+
$functionName = $scope->getFunctionName();
39+
if (! $className || ! $functionName) {
40+
return [];
41+
}
42+
43+
try {
44+
$reflection = new ReflectionMethod($className, $functionName);
45+
} catch (ReflectionException) {
46+
return [];
47+
}
48+
49+
$attributes = $reflection->getAttributes();
50+
51+
// Parse only Routes
52+
$routeAttribute = $this->getAttribute(Route::class, $attributes);
53+
if (! $routeAttribute) {
54+
return [];
55+
}
56+
57+
if ($this->functionAttributesContain(IKnowWhatImDoingThisIsAPublicRoute::class, $attributes)) {
58+
return [];
59+
}
60+
61+
$isGrantedAttribute = $this->getAttribute(IsGranted::class, $attributes);
62+
63+
// LVL 1 NO PROTECTION AT ALL :: Missing vertical controls : no IsGranted and no denyAccessUnlessGranted (even without subject)
64+
if ($isGrantedAttribute === null && ! $this->isDenyAccessUnlessGrantedCalledInRouteFunction($node, false)) {
65+
return $this->buildError(
66+
sprintf('🛑🔓 SECURITY: Route %s::%s is public !', $className, $functionName),
67+
"Add an #[IsGranted] attribute or use the `denyAccessUnlessGranted` function.
68+
If you are sure that this route should remain public, add the " . IKnowWhatImDoingThisIsAPublicRoute::class . ' attribute',
69+
);
70+
}
71+
72+
if ($this->functionAttributesContain(ThisRouteDoesntNeedAVoter::class, $attributes)){
73+
return [];
74+
}
75+
76+
// LVL 2 VERTICAL ACCESS ONLY :: IsGranted is present BUT no voter is called
77+
if ($isGrantedAttribute === null && ! $this->isDenyAccessUnlessGrantedCalledInRouteFunction($node, true)) {
78+
return $this->buildError(
79+
sprintf('🛑🔓 SECURITY: Route %s::%s is insufficiently protected !', $className, $functionName),
80+
"Pass the 'subject' argument to the \$this->denyAccessUnlessGranted() call.
81+
If you are sure that this route's protection should only on user's permissions, add a ".ThisRouteDoesntNeedAVoter::class." attribute.",
82+
);
83+
}
84+
if ($isGrantedAttribute !== null){
85+
$isGrantedAttributeInstance = $isGrantedAttribute->newInstance();
86+
\assert($isGrantedAttributeInstance instanceof IsGranted);
87+
if ($isGrantedAttributeInstance->subject === null) {
88+
return $this->buildError(
89+
sprintf('🛑🔓 SECURITY: Route %s::%s is insufficiently protected !', $className, $functionName),
90+
"Pass the 'subject' argument to the 'IsGranted' attribute.
91+
If you are sure that this route's protection should only on user's permissions, add a ".ThisRouteDoesntNeedAVoter::class.' attribute.',
92+
);
93+
}
94+
}
95+
return [];
96+
}
97+
98+
/** @param array<ReflectionAttribute<object>> $attributes */
99+
private function functionAttributesContain(string $attributeClass, array $attributes): bool
100+
{
101+
return \count($this->getAttributes($attributeClass, $attributes)) > 0;
102+
}
103+
104+
/**
105+
* @param array<ReflectionAttribute<object>> $attributes
106+
*
107+
* @return ReflectionAttribute<object>|null
108+
*/
109+
private function getAttribute(string $attributeClass, array $attributes): ReflectionAttribute|null
110+
{
111+
$attributes = $this->getAttributes($attributeClass, $attributes);
112+
113+
return \count($attributes) > 0 ? $attributes[0] : null;
114+
}
115+
116+
/**
117+
* @param array<ReflectionAttribute<object>> $attributes
118+
*
119+
* @return array<ReflectionAttribute<object>>
120+
*/
121+
private function getAttributes(string $attributeClass, array $attributes): array
122+
{
123+
return array_values(array_filter(
124+
$attributes,
125+
static fn (ReflectionAttribute $attr) => $attr->getName() === $attributeClass
126+
));
127+
}
128+
129+
/**
130+
* @return array<RuleError>
131+
*/
132+
private function buildError(string $message, string $tip): array
133+
{
134+
return [
135+
RuleErrorBuilder::message($message . "\n")
136+
->tip($tip)
137+
->build(),
138+
];
139+
}
140+
141+
private function isDenyAccessUnlessGrantedCalledInRouteFunction(InClassMethodNode $node, bool $requireSubject): bool
142+
{
143+
$visitor = new class ($requireSubject) extends NodeVisitorAbstract {
144+
private bool $isSecurityCheckFunctionCalled = false;
145+
146+
public function __construct(private readonly bool $requireSubject)
147+
{
148+
}
149+
150+
public function enterNode(Node $node): int|null
151+
{
152+
if (
153+
$node instanceof MethodCall && $node->name instanceof Node\Identifier
154+
&& $node->name->toString() === 'denyAccessUnlessGranted'
155+
&& (!$this->requireSubject || isset($node->args[1]))
156+
) {
157+
$this->isSecurityCheckFunctionCalled = true;
158+
159+
return NodeTraverser::STOP_TRAVERSAL;
160+
}
161+
162+
return null;
163+
}
164+
165+
public function isIsSecurityCheckFunctionCalled(): bool
166+
{
167+
return $this->isSecurityCheckFunctionCalled;
168+
}
169+
};
170+
171+
$traverser = new NodeTraverser();
172+
$traverser->addVisitor($visitor);
173+
174+
$traverser->traverse($node->getOriginalNode()->stmts ?? []);
175+
176+
return $visitor->isIsSecurityCheckFunctionCalled();
177+
}
178+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\DevTools\PHPStan;
6+
7+
use Attribute;
8+
9+
/**
10+
* Add this attribute if the Route is limited to some users with specific permissions, but no horizontal check is needed
11+
* - no "ownership"
12+
* - no user context should allow / deny user from accessing the underlying resources
13+
*/
14+
#[Attribute(Attribute::TARGET_METHOD)]
15+
class ThisRouteDoesntNeedAVoter
16+
{
17+
}

0 commit comments

Comments
 (0)