How to ensure that all the routes on my Symfony app have access control

th0masso

Thomas Hercule

Posted on January 17, 2024

How to ensure that all the routes on my Symfony app have access control

TL;DR

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] }
Enter fullscreen mode Exit fullscreen mode

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:

ACCENT 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
            }
            // ...
        }
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Install it with : composer require --dev th0masso/symfony-route-security-checker.

  2. Copy the ssacc-config.dist.yaml from the package repository into your project. Read the documentation to configure it.

  3. Finally, you can generate a detailed report on the access control of your routes with: bin/console security:check-routes

SSACC generated report

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

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) {
            // ...
        }
}
Enter fullscreen mode Exit fullscreen mode

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
            }
            // ...
        }
}
Enter fullscreen mode Exit fullscreen mode

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.

kanye meme

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 or denyAccessUnlessGranted 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))';
Enter fullscreen mode Exit fullscreen mode

Now, it's just a matter of checking if we find the expression in the file.

preg_match($regex, $fileContent)
Enter fullscreen mode Exit fullscreen mode

It worked on the first try; all the routes were secured.

Fake smile

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 .= ')';
Enter fullscreen mode Exit fullscreen mode

Next, I ran my script with this final version of the expression.

ℹ️ You can find the composer package based on this script on github.

Security vulnerabilities found on my project

Script output

46 Routes Without Access Control ?

This is fine

After investigation:

  • 7 routes linked to Symfony modules: web_profiler and twig
  • 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.

💖 💪 🙅 🚩
th0masso
Thomas Hercule

Posted on January 17, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related