OWASP Top 10 Series: Broken Access Control - Part 2
This is the second in a series of blog posts about the OWASP Top 10. You can find the OWASP Top 10 List here .
This is part 2 on the topic of Broken Access Control . Today we’re taking a look at access control vulnerabilities at the data model layer.
In this post, I’m using “data model” to describe the part of the system in the application layer that talks to the database—what you might think of as the “M” in MVC. This often includes entity classes, services, and business logic that decides what data users are allowed to access.
Before we go any further, let’s take another look at the OWASP description of the broken access control vulnerability:
“Access control enforces policy such that users cannot act outside of their intended permissions. Failures typically lead to unauthorized information disclosure, modification, or destruction of all data or performing a business function outside the user’s limits.” (https://owasp.org/Top10/A01_2021-Broken_Access_Control/#description )
In Part 1 of this series we looked at an example where we had an Admin area of the site, but not roles defined to prevent a non-administrator user from accessing the Admin features. This is a violation of the principle of least privilege or deny by default. To solve this, we added identity capabilities to restrict non-Admin users from accessing the Admin features. This works great for areas of the site controlled by our role based policies. While this is a great start, there are more steps we need to take to ensure users can’t access data they aren’t authorized to.
For our simple example, let’s assume we have a system that users use to manage customer data. For these examples, I’m using the same .NET web application that was used for part 1, but I’ve expanded the capabilities of the demo web application. Using a SQL Server Express back end, I’ve set up a fully functioning ASP.NET Identity solution for authentication and added two user accounts in the database.
The emails I have used are my own, so I’ve blocked those from view.
I’ve created two sample tables in the database for managing customer data. A Customers table that stores the customer information and a UserCustomers table that is used to link user accounts to the customers they are authorized to manage.
The Customers table has two customers in it:
The UserCustomers table links the users to one of the two customers. Each user account is currently authorized to one customer.
Here is our current Log In page:
Now, let’s log in as the first user, we’ll call User1, that has access to CustomerId 1. I’ve set up the ASP.NET application to present a Customers link to authenticated users. When I’m logged in as User1, clicking on this link takes me to the Customer page, and I can see just the one customer this user has access to:
So far, so good. I only see the one customer. You’ll notice I’ve included the address bar in this screenshot. This will eventually become important for this example. If you already have a good understanding of broken access controls, and specifically IDOR , then you very likely already know where this example is going.
Let’s click on the View button to view the customer details:
Great! User1 can access the Details page. In a real, functioning application, maybe there would also be options to edit or activate/deactivate a customer.
Now, let’s check the second user, which we’ll call User2.
I’ve logged out of the User1 account, logged in to the User2 account and accessed the customers page. As expected, I only see Customer Two:
And when I click the view button, I now see the details for Customer Two:
So, we’ve demonstrated that we have a functioning authentication system. Users are able to authenticate with their user name and password, proving who they say they are. In a production system, we would also want to require 2 factor authentication. I don’t have that set up for this simple demonstration. The point is that the users are authenticated via login and the application is set up to authorize access to customer data based on the identity of the authorized user.
We’ve also proven that the customer access is configured correctly in the database and the users see the correct customers when they access the Customer page.
Everything is good, right? Well, no. Did you notice from the earlier screenshots that we have the customer ID in the URL?
What happens if User2 changes the customer ID in the URL? While logged in as User2, I’ll change the customer ID to a 1.
That’s a problem. I just accessed Customer One as User2, and that customer should only be accessible by User1.
This type of vulnerability is a type of IDOR vulnerability. Changing the URL parameter is a type of attack known as parameter tampering, which is a common attack used to exploit these types of vulnerability.
So, how do we prevent this? Well, one way to do this is to add security at the data service layer within the “Model” of the MVC architecture.
If user accounts map to actual database accounts, a development team might consider restricting access within the database itself. This is usually done by creating user groups at the database level, granting access to the appropriate resources to the groups and then assigning individual user accounts to the groups. This is common for internal applications where the number of users is relatively low.
For larger scale applications, however, managing access using database security is impractical. Instead, user management is typically handled by the application layer. The example used for this post, using the Customers and UserCustomers tables, is a typical solution for applications that must manage application users that don’t map to internal, business user accounts.
So, instead of adding security at the database layer, we’re going up a little higher into the application’s service layer, and we’ll look at an example of how to resolve the problem there instead.
The example application I have set up uses a CustomerService class to get the user information. This demonstration class currently has two functions for retrieving customer data: GetCustomers, which takes a userId and returns the customers assigned to the user, and GetCustomer, which takes an Id parameter that corresponds to the Id of the customer the application should retrieve from the database. The application is also set up to use EntityFramework , an object-relational-mapper (ORM) framework that allows applications to work with data as objects.
using OwaspTopTen.Models;
namespace OwaspTopTen.Services
{
public class CustomerService
{
private AppDbContext _context;
public CustomerService(AppDbContext context) {
_context = context;
}
public List<Customer> GetCustomers(string userId)
{
return _context.Usercustomers
.Where(x => x.UserId == userId)
.Select(x => x.Customer).ToList();
}
public Customer GetCustomer(int Id)
{
return _context.Customers.Find(Id);
}
}
}
Here is the code behind .cshtml.cs code for the Details action of the Customer Page.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using OwaspTopTen.Models;
namespace OwaspTopTen.Pages.Customers
{
public class DetailsModel : PageModel
{
public Customer Customer { get; set; }
public IActionResult OnGet(int id)
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.Build();
var connectionString = config.GetConnectionString("AppDbContextConnection");
var options = new DbContextOptionsBuilder<OwaspTopTen.Services.AppDbContext>()
.UseSqlServer(connectionString)
.Options;
var context = new OwaspTopTen.Services.AppDbContext(options);
var customerService = new OwaspTopTen.Services.CustomerService(context);
Customer = customerService.GetCustomer(id);
if (Customer == null)
{
return NotFound();
}
return Page();
}
}
}
With these two examples, we can see the problem. The OnGet method of the DetailsModel takes the customer ID as a parameter. This value is then passed to the GetCustomer function, which takes the id, finds the customer with that ID in the database, and then returns it.
To fix this vulnerability, we can add some additional code to the GetCustomer method that checks to make sure the user is authorized to the customer and returns null if the user is not authorized.
First, we’ll add the userID as a parameter to the GetCustomer function:
public Customer GetCustomer(int Id, string userId)
{
return _context.Customers.Find(Id);
}
Then, we’ll update the GetCustomer method to use the userId when searching for customers, ensuring only customers the user is authorized to are queried. We’ll also add a comment block to clarify the purpose of the function:
/// <summary>
/// Gets a customer if the userId is authorized.
/// </summary>
/// <param name="Id">The Id of the Customer</param>
/// <param name="userId">The userId of the user the customer is being requested for.</param>
/// <returns>
/// <para>An instance of Customer, if found and the user is authorized.</para>
/// <para>Returns null if the Customer is not found or the user is not authorized.</para>
/// </returns>
public Customer GetCustomer(int Id, string userId)
{
return _context.Usercustomers
.Where(x => x.UserId == userId && x.CustomerId == Id)
.Select(x => x.Customer)
.FirstOrDefault();
}
Adding FirstOrDefault will return either the customer, if a customer with a matching id is found, or null, if a customer with a matching id is not found.
Now, we need to change how our model calls the GetCustomer function. We have to get the currently logged in user and pass that into the new required parameter:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using OwaspTopTen.Models;
using System.Security.Claims;
namespace OwaspTopTen.Pages.Customers
{
public class DetailsModel : PageModel
{
public Customer Customer { get; set; }
public IActionResult OnGet(int id)
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.Build();
var connectionString = config.GetConnectionString("AppDbContextConnection");
var options = new DbContextOptionsBuilder<OwaspTopTen.Services.AppDbContext>()
.UseSqlServer(connectionString)
.Options;
var context = new OwaspTopTen.Services.AppDbContext(options);
var customerService = new OwaspTopTen.Services.CustomerService(context);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
Customer = customerService.GetCustomer(id, userId);
if (Customer == null)
{
return NotFound();
}
return Page();
}
}
}
Our page is already set up to check if we receive a null Customer object.
Let’s run the application again, log in as User2, and access the details for Customer Two, the only customer this user is authorized to:
Ok, we can see the details for Customer Two. Now, I’ll try tampering with the parameter again and enter a 1 in the URL.
Now, instead of receiving the data for Customer One, the user is redirected to a generic 404 error page. Since this is just a basic example, we’re redirecting the user to a generic 404 error page with the text “Page Not Found”. It’s outside of the scope of this post, but this redirection is part of the default behavior for NotFound(), given the way I have the application configured.
Depending on the needs of your application, you might want to direct the user to a more specific, customer related action, specifically stating that the customer is not found or maybe that the user is not authorized to the customer. This will depend on the needs of your application. In practice, I prefer using a 200 response and directing the user to a custom “Customer Not Found” page, which could easily be done in the DetailsModel by using a redirect instead of returning NotFound().
Whether or not a 200 , a 403 or a 404 is used, this approach helps reduce the risk of leaking information to unauthorized users.
The pros of this approach are that it is flexible and easy to understand. The cons are that it can become inconsistent or duplicated if the system is not designed well. In larger systems, centralizing access control into reusable services can help mitigate the inconsistency issue. One approach that already does this, although outside of the scope of this post, is policy-based authorization .
In this post, we explored how broken access control vulnerabilities can exist at the data access layer of a web application. We looked at a common IDOR (Insecure Direct Object Reference) issue where the user manipulated a URL parameter to access data they weren’t authorized to see. We then demonstrated how to prevent this by validating user access to authorized data in the service layer (part of the “M” or Model layer in MVC), ensuring only properly linked data is returned. I hope this gave you a clearer understanding of this type of vulnerability—and a practical way to prevent it in your own application.
The content on this blog is for informational and educational purposes only and represents my personal opinions and experience. While I strive to provide accurate and up-to-date information, I make no guarantees regarding the completeness, reliability, or accuracy of the information provided.
By using this website, you acknowledge that any actions you take based on the information provided here are at your own risk. I am not liable for any losses, damages, or issues arising from the use or misuse of the content on this blog.
Please consult a qualified professional or conduct your own research before implementing any solutions or advice mentioned here.