How to ensure that all the routes on my Symfony app have access control
Thomas Hercule
Posted on January 17, 2024
TL;DR
- Effective access control involves both Symfony's firewall and specific control on each route.
- If you use API Platform, use ACCENT to verify access control on your routes.
- If you use functions in the controller to secure your routes, you can install the composer package I created based on the following part of this article.
- If you're not using API Platform nor functions, you can create a custom script.
- It's important to integrate access control verification into your CI process to maintain a high level of security.
What is access control
Access control allows you to define access permissions to specific parts of your application. It helps restrict access to certain pages or features for users who do not have the necessary permissions.
To implement access control, you need to define user roles and corresponding permissions, then apply them to the routes of your application. This can be especially useful for safeguarding sensitive information or important actions, such as modifying or deleting data.
Implementing access control for routes significantly enhances the security of your Symfony project and safeguards your users’ data.
Effective access control in your Symfony project involves two main aspects:
- Symfony Firewall
- Specific access control for each route
Symfony firewall
The Symfony firewall is the initial layer of security for routes, adding global rules to all routes or specific groups of routes.
The configuration for this firewall is typically found in config/packages/security.yaml
.
In this file, you can:
- Define which URL groups require (or do not require) security checks (logged-in user, specific role, GET/POST requests, etc.).
- Whitelist IPs for certain endpoints.
Generally, you would grant access to the site only to logged-in users (the default behavior) and disable this check for specific pages (login, password reset, etc.).
Here's an example of a typical security.yaml
configuration where we ensure the user is logged in for all routes except the login and register pages.
access_control:
- { path: ^/login, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: [ROLE_USER, ROLE_ADMIN] }
The role IS_AUTHENTICATED_ANONYMOUSLY
allows access to pages even for non-authenticated users. You can find more details in the Symfony firewall documentation.
Although it's possible to have role-based access control in the firewall, it's preferable to implement it on each route:
- To handle more complex rules.
- To make the access control directly visible on the relevant functions.
If you have a Symfony project with more than a dozen routes, as it was the case on my project, you will need a tool to automatically ensure they all have access control.
How to ensure access control for all your symfony routes
Depending on the type of project, different solutions can be used to verify the security of these routes. If you are using API Platform, you can save time by utilizing ACCENT. Otherwise, it is possible to create a custom script since there is no pre-existing tool to automate this verification.
My project uses API Platform: using ACCENT
If you use API Platform, you can use ACCENT. It is a powerful tool to generate a report on the access control of each of your routes with very little configuration.
To install ACCENT, run: composer require --dev theodo/accent-bundle
.
Then, you can generate a detailed report on the access control of your routes with: bin/console theodo:access-control
Example of a generated report:
Above you can see the list of all the projects’ routes with, highlighted in red, the routes that lacks access control.
My project doesn't use API Platform
My project uses functions to secure routes
If your project uses functions to secure your routes in the controllers as such :
class AdminController extends AbstractController
{
public function createUser(Request $request) {
if (!$securityService->is('admin')) {
// We redirect the user to the login page
}
// ...
}
}
It also works with Symfony's function (isGranted
and denyAccessUnlessGranted
for example).
You can use the composer package I created based on the script I build in the next part of this article : symfony-route-security-checker.
Install it with :
composer require --dev th0masso/symfony-route-security-checker
.Copy the
ssacc-config.dist.yaml
from the package repository into your project. Read the documentation to configure it.Finally, you can generate a detailed report on the access control of your routes with:
bin/console security:check-routes
Other cases
You can create a custom script specific to your project. In this section, we will use an example of a script that checks if Symfony security functions are called at the beginning of each function related to routes. If you are using something other than Symfony Security function, this section can still help you write such a script.
Plan for this section:
- Access control with Symfony Security
- Why write a script?
- Retrieving the list of project routes
- Verifying that the function has access control
Access control with Symfony security
There are several ways to secure routes "manually" using Symfony Security functions: isGranted
and denyAccessUnlessGranted
.
In annotations:
use Symfony\Component\Security\Http\Attribute\IsGranted;
class AdminController extends AbstractController
{
#[IsGranted('ROLE_ADMIN')]
public function createUser(Request $request) {
// ...
}
}
Or by directly calling these functions:
use Symfony\Component\Security\Http\Attribute\IsGranted;
class AdminController extends AbstractController
{
public function createUser(Request $request) {
if (!$this->isGranted('ROLE_ADMIN')) {
// We redirect the user to the login page
}
// ...
}
}
I have worked on a project where we controlled access to our routes by calling isGranted
or denyAccessUnlessGranted
within our functions. However, the method I used to verify access control on our routes can also work with annotations with minor modifications.
Why write a script?
As there is no existing tool to automatically verify routes in these cases, we have two solutions to ensure that routes have specific access control:
- Manually check the routes.
- Create a script to do it for us.
Both of these solutions provide a snapshot of the current state. However, creating a script automates the access control verification for future routes and could potentially be shared and used in other projects.
In my case, I knew there were over a hundred routes in the project, so doing it manually would have been very redundant.
Retrieving the list of project routes
Symfony provides a command to list all the routes in the project along with their paths.
php bin/console debug:router --show-controllers --format=json
Now, for each route in the project, we have its path in the format: MyPath/MyController.php::MyFunction
Verifying that the function has access control
Now that we know which functions are associated with which routes and their paths, we can check in the files whether these functions indeed have calls to permission-checking functions such as isGranted
or denyAccessUnlessGranted
.
These functions can be called through annotations or directly within the controller function.
Using annotations to restrict access
If you use annotations to secure your routes, you can:
- recover the annotation of the controller's function as a string with PHP's ReflectionProperty::getDocComment
- then search for
isGranted
ordenyAccessUnlessGranted
using a regular expression.
Since I didn't use annotations on my project, I will not go into details on this method.
Calling functions to restrict access
If you call some permission-checking functions directly into your controller, it's possible to ensure that these functions are called at the beginning of the function used by the route in two ways:
- Using a regular expression
- Using the Abstract Syntax Tree (AST)
AST should be more robust because it can handle more complex cases. For example, with AST, we can determine if the function is called in a condition, a loop, or another function. However, within my team, no one had experience with AST manipulation, and we didn't encounter such complex cases in my project. So, I opted for a solution using a regular expression.
I then wrote a regular expression that matches !$this->isGranted
or $this->denyAccessUnlessGranted
on the first line of the function used by the route (⚠️ please do not read this monstrosity):
$regex = '((public function ' . $routeFunction . ')(\([^{]*\{)(\s.*)(\$this->denyAccessUnlessGranted|!\$this->isGranted))';
Now, it's just a matter of checking if we find the expression in the file.
preg_match($regex, $fileContent)
It worked on the first try; all the routes were secured.
I’m kidding, I'm not fluent in regular expressions, and I want others to be able to read and understand this script, so it took me a few hours.
ℹ️ Fortunately, I was advised to use regex101.com, an incredibly useful website for testing and understanding complex regular expressions.
Then, I broke down and commented the expression to make it more understandable:
// start of regular expression
$regex = '(';
// find "public function myFunction"
$regex .= '(public function ' . $function . ')';
// then everything until "{"
$regex .= '(\([^{]*\{)';
// then everything on next line
$regex .= '(\s.*)';
// until "$this->denyAccessUnlessGranted" or "!$this->isGranted"
$regex .= '(\$this->denyAccessUnlessGranted|!\$this->isGranted)';
// end of regular expression
$regex .= ')';
Next, I ran my script with this final version of the expression.
ℹ️ You can find the composer package based on this script on github.
- The file with the regex and the rest of the logic is on the same repo :
src/Command/AccessControlCheckerCommand.php
.
Security vulnerabilities found on my project
46 Routes Without Access Control ?
After investigation:
- 7 routes linked to Symfony modules:
web_profiler
andtwig
- 6 routes that should not be verified, therefore accessible to everyone (login page, password recovery, etc.)
- 4 exposed API routes, which should not be verified in this way
- 9 false positives due to special cases
- 3 unused routes (cleaning up dead code 🤩)
- 17 routes that were indeed problematic
After identifying where each of these 17 routes was being used on the site, I realized the importance of having a strict access control strategy for your app.
For these routes without permission checks, most were harmless, but some allowed critical actions on the site! This means that any logged-in user could perform these actions if they sent the right HTTP request (thanks to Symfony's firewall rejecting non-logged-in users). However, there was no risk of privilege escalation.
To secure these 17 routes, I organized a meeting with the client to define the access control to be applied for each one.
Afterwards, the client lived happily ever after, and the site wasn't hacked due to an access control issue... until the day a developer introduced a new route without permission!
How to ensure new routes are secure too
To ensure that new routes added are also secure, it's important to run this verification script automatically. The best way is to add it to your CI (Continuous Integration) to ensure that the script runs before each merge. If you can't add it to your CI for some reason, you can add it to your git pre-commit or pre-push hook it's not as good because those hooks can be ignored by the developer by using --no-verify
after git commit
or git push
.
Unfortunately, I didn't have the time to implement it in my project's CI due to time constraints.
Although the performance of my script is quite good, it still takes a few seconds to run if you have hundreds of routes (approximately 5 seconds for 200 routes). To overcome this issue, one approach could be to run the script only on modified files, which would significantly reduce processing time.
Posted on January 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.