How to ensure that all the routes on my Symfony app have access control
Tags: symfony, security, php, webdev
Published: 2024-01-17 10:15:05

**TL;DR**

- Effective access control involves both [Symfony's firewall](#symfony-firewall) **and** [specific control on each route](#how-to-ensure-access-control-for-all-your-symfony-routes).
- If you use API Platform, [use ACCENT to verify access control on your routes](#my-project-uses-api-platform-using-accent).
- If you use functions in the controller to secure your routes, [you can install the composer package I created](#my-project-uses-function-to-secure-routes) based on the following part of this article.
- If you're not using API Platform nor functions, [you can create a custom script](#other-cases).
- It's important to [integrate access control verification into your CI process](#how-to-ensure-new-routes-are-secure-too) to maintain a high level of security.

<h1 id='what-is-access-control'>What is access control</h1>

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

<h1 id='symfony-firewall'>Symfony firewall</h1>

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.

```yaml
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](https://symfony.com/doc/current/security/access_control.html)**.

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.

<h1 id='how-to-ensure-access-control-for-all-your-symfony-routes'>How to ensure access control for all your symfony routes</h1>

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.

<h2 id='my-project-uses-api-platform-using-accent'>My project uses API Platform: using ACCENT</h2>

If you use API Platform, you can use [ACCENT](https://github.com/theodo/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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1tpj5cgtpijikvphoel5.png)

Above you can see the list of all the projects’ routes with, highlighted in red, the routes that lacks access control.

<h2>My project doesn't use API Platform</h2>

<h3 id='my-project-uses-function-to-secure-routes'>My project uses functions to secure routes</h3>
If your project uses functions to secure your routes in the controllers as such :

```php
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**](https://github.com/Th0masso/symfony-security-access-control-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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/03a8cre9408eicrtrm6w.png)


<h3 id='other-cases'>Other cases</h3>

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](#access-control-with-symfony-security)
- [Why write a script?](#why-write-a-script)
- [Retrieving the list of project routes](#retrieving-the-list-of-project-routes)
- [Verifying that the function has access control](#verifying-that-the-function-has-access-control)

<h3 id='access-control-with-symfony-security'>Access control with Symfony security</h3>

There are several ways to secure routes "manually" using Symfony Security functions: **`isGranted`** and **`denyAccessUnlessGranted`**.

In annotations:

```php
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:

```php
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.

<h3 id='why-write-a-script'>Why write a script?</h3>

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ytcwht4t7652c3jjhqfe.jpg)

<h3 id='retrieving-the-list-of-project-routes'>Retrieving the list of project routes</h3>

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`**

<h3 id='verifying-that-the-function-has-access-control'>Verifying that the function has access control</h3>

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](https://symfony.com/bundles/SensioFrameworkExtraBundle/current/annotations/security.html) or directly within the controller function.

<h4> Using annotations to restrict access </h4>

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](https://www.php.net/manual/en/reflectionproperty.getdoccomment.php)
- 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.

<h4> Calling functions to restrict access </h4>
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):

```php
$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.

```php
preg_match($regex, $fileContent)
```

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

![Fake smile](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ptb4za1h7l2c0wfqwxpj.png)

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](https://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:

```php
// 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.](https://github.com/Th0masso/symfony-security-access-control-checker)**
- The file with the regex and the rest of the logic is on the same repo : [`src/Command/AccessControlCheckerCommand.php`](https://github.com/Th0masso/symfony-security-access-control-checker/blob/main/src/Command/AccessControlCheckerCommand.php).

<h2 id='security-vulnerabilities-found-on-my-project'>Security vulnerabilities found on my project</h2>

![Script output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j4yjd8484p0s0dxa0g56.png)

**46 Routes Without Access Control ?**

![This is fine](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/53ccqgce7sqw7hx6pllq.png)

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!

<h2 id='how-to-ensure-new-routes-are-secure-too'>How to ensure new routes are secure too</h2>

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](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) 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.