How to protect yourself against insecure object direct reference in Sonata Admin.
Michaël Mollard5 min read
You think that your entities need some finer access controls?
Changing the url in your admin panel gives access to hidden forms?
You’ve heard of ACL (Access Control List) but can’t really see it as a feasible solution?
If so then you’re just like me.
I’ve started working on a decently sized project with a backend powered by Sonata for the last few weeeks when I was tasked with granting edit access for certain admins to edit an entity they own and nothing else.
Problem: My problem was that my security configuration was set to use Roles and the application itself was too big to switch to an ACLs approach.
If this is also your case then let me take you through my solutions.
Some context for easier understanding
Let’s imagine a really simple application.
You are the president of a group managing hundreds of hotels all over the world each supervised by a different general manager.
Your application is to be used both by you and each manager to store information about each of to store and manage information on each of those hotels.
Now in this simplest form, the application need to have two entities. A User and a Hotel entity.
Based on those requirement, you have two roles emerging:
-
President: He should be able to list, show, edit, create, delete all Hotel object. He is basically the super admin.
-
General Manager: He should only be able to show and edit a single object. Only one Hotel.
One of those role will be given to a User object at creation.
This means that your security.yml has to look something like that:
#app/config/security.yml
ROLE_GENERAL_MANAGER:
- ROLE_APP_ADMIN_HOTEL_SHOW
- ROLE_APP_ADMIN_HOTEL_EDIT
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
This gives too much right to the general manager.
He can easily acces any hotel information simply by changing the id that will appear in the url when he is accessing his own.
What can we do to fix that?
The quick and dirty way
The part where the request is handled is the controller. So the first thing that comes to mind is to put the security logic there.
For that we need to understand that sonata uses a default CRUD controller for all of its admin classes. To implement our custom logic, we need to override this behaviour.
We start by extending the current controller in our bundle and implement our little security logic.
// AppBundle/Controller/CRUDController.php
class CRUDController extends Controller
{
/**
* Override the default editAction to only allow a General Manager to modify it's hotel
*
* @param $id
* @return Response
*/
function editAction($id = null)
{
$user = $this->getUser();
// We assume here that the user has a function that return the Hotel he is managing
$hotel = $user->getHotel();
if ($user->hasRole('ROLE_GENERAL_MANAGER') and $id != $hotel->getId()) {
throw new AccessDeniedException();
}
return parent::editAction($id);
}
}
And then we add the controller as the one to be used by the Hotel admin.
app.admin.hotel:
class: AppBundle\Admin\Hotel
tags:
- { name: sonata.admin, manager_type: orm, group: app }
arguments: [null, AppBundle\Entity\Hotel, AppBundle:CRUD]
Now each time we try to edit an hotel we are not managing we will get the desired 403 error.
This way of doing things have two main disadvantages.
- We don’t have access to the object itself which could be useful to implement the ownership logic.
- Our security logic is present in the controller and not isolated.
Security voters
If we look at the code in the sonata default CRUD controller we can notice those 3 lines of code checking for access on an instance of an entity.
if (false === $this->admin->isGranted('EDIT', $object)) {
throw new AccessDeniedException();
}
Behind the scene, the isGranted function will start the voter security process of Symfony.
It will ask voters to decide if the current user can perform an action (here “EDIT”) on a certain object.
The voters will then judge and give out an answer.
To handle the case of multiple voters, it is useful to change the voting strategy to unanimous in the security.yml of the application.
This mode means that if any voter were to block access to an object then the access would be blocked even if another one were to allow access.
This allows for a finer security configuration by stacking voters on the same class of object based on different conditions.
This can be done by adding the following:
# app/config/security.yml
security:
access_decision_manager:
strategy: unanimous
To get back to our hotel and it’s security, to ban General Manager from modifying the hotel that do not belong to them, we need to define a security voter that supports “EDIT” and the Hotel class.
To do that, we need to extend the base Voter class and override two of its functions:
// AppBundle\Security\HotelVoter.php
class HotelVoter extends Voter
{
private $decisionManager;
public function __construct(AccessDecisionManagerInterface $decisionManager)
{
$this->decisionManager = $decisionManager;
}
protected function supports($attribute, $object)
{
// if the attribute isn't one we support, return false
if (!in_array($attribute, array("ROLE_APP_HOTEL_EDIT"))) {
return false;
}
// only vote on Hotel objects inside this voter
if (!$object instanceof Hotel) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $object, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
// the user must be logged in; if not, deny access
return false;
}
// ROLE_SUPER_ADMIN can do anything
if ($this->decisionManager->decide($token, array('ROLE_SUPER_ADMIN'))) {
return true;
}
return $user === $object->getManager();
}
}
All that’s left is to register the security voter as a service with the right tags:
# AppBundle/Resource/voters.yml
app.hotel_voter:
class: AppBundle\Security\HotelVoter
tags:
- name: security.voter
Now when our crafty admin try to access any hotel he is not managing, he will be faced with desired 403 error ;)