Broken Access Control vulnerabilities are pervasive. Since its inception in 2001, the Open Web Application Security Project (OWASP) has been tracking web vulnerabilities. Last year, Broken Access Control rose from #5 to #1 on the OWASP top 10 list. It’s clear that while web application frameworks have greatly improved in the last decade, support for better access control has largely stood still.
When you look for a standard access control framework or paradigm, you won’t find one. So applications and microservices typically implement access control as part of the application logic. This leads to a proliferation of permission enforcement logic and makes it difficult for security engineers to reason about the access control surface area of the application.
Let’s take a look at how applications can progressively move to a more secure foundation and eliminate broken access control vulnerabilities.
Defining Broken Access Control
The OWASP categorizes Broken Access Control as failures that lead to
"...unauthorized information disclosure, modification, or destruction of all data or performing a business function outside the user's limits."
There are numerous types of access control vulnerabilities. In this post, we’ll focus on this particularly common pattern:
"Bypassing access control checks by modifying the URL (parameter tampering or force browsing), internal application state, or the HTML page, or by using an attack tool modifying API requests."
In these scenarios, the web application relies on untrusted information passed in by the caller (whether in the URL or the request body) to circumvent the access control logic. The attack described by MITRE’s CWE-639: Authorization Bypass Through User-Controlled Key is a great example of this pattern.
Let’s make this concrete through an example - implementing access control for a “Todo” application. Below we have an Express.js handler for updating the value of a todo:
interface Todo {
id: string;
title: string;
completed: boolean;
ownerID: string;
}
interface User {
id: string;
name: string;
role: string;
}
app.put("/todo/:id", checkJwt, async (req, res) => {
const todo: Todo = req.body;
try {
// retrieve the user from the database using the subject claim in the JWT
const user = getUser(req.user.sub)
// allow the operation if the user is an admin
// ...or if the user is an editor and the user owns this todo
if (user.role === "admin" ||
user.role === "editor" && todo.ownerID === user.id) {
await updateTodo(todo)
res.json({ msg: "Todo updated" })
} else {
res.status(403).send({ "msg": "Access denied" })
}
} catch (error) {
res.status(500).send(error);
}
})
Can you spot what’s wrong with this code?
The handler correctly retrieves the user from the application’s data store using the “subject” claim in a signed and validated JWT. So far so good.
It then checks that the role of the user is an “admin” or an “editor,” and if it’s an “editor,” makes sure that the ownerID of the Todo is the same as the ID property of the user.
But what prevents the caller from passing a different ownerID field in the payload? The application is implicitly trusting a piece of information passed in by the caller, instead of retrieving the Todo from the database and only trusting the ownerID property that came from the database.
As you can see, this type of logic error is very easy to miss, even for experienced engineers. And it only gets worse as the access control logic surface area grows, with the addition of roles and permissions. With time, this access control “spaghetti logic” becomes next to impossible to reason over and maintain.
What can we do to mitigate this type of issue systematically?
Step 1: treat authorization as a cross-cutting concern
The first step is architectural. Rather than each part of the application implementing access control differently, authorization needs to be treated as a cross-cutting concern.
Concretely, this means creating a purpose-built authorization service that offers a consistent authorization API for every other part of the application. This allows application developers to get out of the business of writing access control “spaghetti logic,” and instead call an authorization API before performing an operation on a protected resource.
Below, we use the check_permission
function of an “authz” service to check that the user has the ‘todo.update’ permission on the todo that is passed in. This simplifies the code and allows consistent handling of authorization checks.
app.put("/todo/:id", checkJwt, async (req, res) => {
const todo: Todo = req.body;
try {
if (authz.check_permission(req.user.sub, 'todo.update', todo)) {
await updateTodo(todo)
res.json({ msg: "Todo updated" })
} else {
res.status(403).send({ "msg": "Access denied" })
}
} catch (error) {
res.status(500).send(error);
}
})
This code still relies on the application developer to call the authorization service correctly. We can further abstract this code into middleware, which automatically calls the authorization service with the user context, permission (derived from the HTTP method and route), and resource context (extracted from the payload).
app.put("/todo/:id", checkJwt, checkAuthz, async (req, res) => {
const todo: Todo = req.body;
try {
await updateTodo(todo)
res.json({ msg: "Todo updated" })
} catch (error) {
res.status(500).send(error);
}
})
This code is much harder to get wrong and provides a consistent authorization pattern for every route handler in every microservice.
Step 2: express authorization logic in a policy language
Now that we have a purpose-built authorization service, how should we implement the authorization logic?
If we simply use the same programming language, we end up largely duplicating the access control “spaghetti logic” that we had in each route handler. Fortunately, we have alternatives.
Open Policy Agent (OPA) is an open-source decision engine that allows us to externalize authorization logic into a policy.
We can write a policy for the “PUT /todo/:id” route, which takes the user and resource as inputs. This policy largely does what the authorization logic did for us, but is stored and versioned in a separate repository, which allows us to track and audit every change to the authorization policy. Using OPA also allows us to log every decision that this policy makes so that we have an audit trail of the policy evaluations.
In the policy below, we’ll use the check.is_in_group
function to allow a user that is in the “admin” group, or a user that is in the “editor” group and also is the owner of the resource.
package todoApp.PUT.todo.__ID
default allowed = false
allowed {
check.is_in_group(input.user, "admin")
}
allowed {
check.is_in_group(input.user, "editor")
input.user.key == input.resource.ownerID
}
However, this policy still assumes that the caller passes in the correct information. While the user can be extracted out of the JWT, the resource payload still contains an “ownerID”, and still relies on the middleware to retrieve this piece of data from a trusted source (like our Todo database), instead of trusting a value that is passed in by the caller.
We can take this one step further by delegating Todo ownership tracking and validation to the policy.
Step 3: use a relations database to track objects and relations
Most applications store the relationships between subjects (users) and objects (resources) in their own database. But we’re now seeing a new pattern emerging around these scenarios called Relationship-based Access Control (ReBAC). ReBAC has been popularized via Google’s Zanzibar paper, where they describe a purpose-built authorization service for Google Drive, Gmail, Google Cloud, and many other Google services.
With ReBAC, resources (in our case, Todos) have named relationships to subjects (users or groups). We could change the policy to use check.is_in_role
to evaluate whether the user has the “owner” relationship to the “todo” object that is passed in as the resource.
We still need the caller to pass in the resource, but the relationship validation is done inside the policy. In this case, the check.is_in_role
function is implemented over a relations database that stores the ownership relationships between users and todos.
package todoApp.PUT.todo.__ownerID
default allowed = false
allowed {
check.is_in_group(input.user, "admin")
}
allowed {
check.is_in_group(input.user, "editor")
check.is_in_role(input.user, "todo", "owner", input.resource)
}
This does mean that the application needs to tell the relations database about ownership relationships between users and Todos. But using a standard component means that the application doesn’t have to reinvent this wheel, and reduces the probability of getting this wrong.
Bonus: fine-grained access control
As a final step, we could move away from coarse-grained roles (such as “admin” and “editor”) that are a property of the user, to named relationships on each object. In other words, a user can be an “owner” or “editor” of each Todo. This is trivial to do when you have a relations database. This policy allows the operation if the user has either an “owner” or “editor” relationship to the resource (Todo) that is passed in by the caller:
package todoApp.PUT.todo.__ownerID
default allowed = false
allowed {
allowedRoles := {"owner", "editor"}
some x in allowedRoles
check.is_in_role(input.user, "todo", allowedRoles, input.resource)
}
For more about the differences between coarse roles and fine-grained access control, go here.
Conclusion
We’ve explored the following three techniques that can be combined to create secure-by-default applications that avoid/eliminate Broken Access Control vulnerabilities:
- Extract authorization logic into a separate authorization service that provides a standard and consistent API for the rest of the microservices in your application.
- Implement this authorization service using a standard policy engine such as Open Policy Agent (OPA). This allows you to reason about the authorization policy as code and provides a natural git-based workflow for versioning the policy and audit trails for decisions.
- Use a ReBAC-based permission database to manage the relationships between users/groups and resources in your application.
By combining these techniques, you’ll reduce the surface area that application developers need to “get right,” and make it much easier to build applications that are free from Broken Access Control vulnerabilities.
Topaz
Topaz is an open-source authorization service that combines OPA as a decision engine and a ReBAC permissions database. If you’re in the process of cleaning up your authorization logic, you can use Topaz to do much of the heavy lifting.
Related Content
A CISO perspective on Broken Access Control
A CISO view of what Broken Access Control is, why it keeps them up at night, and some strategic priorities your organization can pursue to address and mitigate broken access control threats.
Jan 4th, 2023
Solving cloud-native authorization
Cloud-native authorization is hard. This post covers exactly why and how to build fine-grained access control systems for cloud-native applications. We highlight a couple open-source projects you can use today, as well as the "5 laws of cloud-native authorization."
Mar 14th, 2023
Five common authorization patterns
In this post we share five common authorization patterns, starting from the simplest IDP-based RBAC, and culminating in a combination of group-based RBAC with fine-grained permissions and fine-grained resources. You can easily evolve from the simple models to the more sophisticated ones, by evolving the authorization policy using Topaz.
Mar 22nd, 2023