Broken object level authorization (BOLA) is a common website vulnerability. It happens when a web application or API fails to check user entitlements properly. As a result, attackers can access sensitive website data with little or no website permissions, leading to serious security breaches. All web developers need to be aware of BOLA and how to prevent it.
This article will look at examples of broken object level authorization (BOLA) problems in Laravel applications and how you can fix and prevent them.
Broken Object Level Authorization
When object level authorization works, it checks a user's entitlements before allowing them to access information. We call it "object-level" because applications have to perform this kind of authorization on individual objects, as clients request them.
Responsibility for object level authorization falls to application code. Third-party services and libraries work for authentication, and they may be able to provide applications with information about users after identifying them. But applications need to verify access to specific objects.
Therefore, BOLA is when an application doesn't verify access or fails to do it properly. This means that users can read, delete, create, or update data they shouldn't be allowed to.
BOLA problems are easy to exploit.
Many attacks are waged with a simple shell script, so attackers take advantage of as soon as they're detected. Also, because of the nature of the issues, it's easy to steal large amounts of data.
Let's look at an example to see why.
Broken Object Level Authorization in a REST API
A Simple API
Let's design a REST API for managing comic books.
The API represents Comics with JSON objects that look like this:
{
"id":"1",
"publisher": "DC Comics",
"title":"Action Comics",
"issue":"100",
"price": "$10000"
"count": "10",
}
The API has endpoints for managing comic book records.
POST /comics/ with a JSON record to add a new comic to inventory.
GET /comics/{ID} to retrieve a book by Id.
GET /comics/ with no argument retrieves a list of all comics
PUT /comics/{ID} with a JSON record updates an existing comic with new information.
DELETE /comics/{ID} deletes a comic
The BOLA Problem
In a typical inventory system, different users have different levels of access.
Store managers can add new comics, change their inventory level, and delete comics from the database.
Salespeople can retrieve books and adjust inventory levels when they sell a book.
Customers can retrieve information about comics.
If you know a comic book's Id, you can retrieve, update, or delete it. If you know to call /comics/ with a GET and no arguments, you get a list of all of the comics in inventory, with their Ids. This might not seem like a big problem, but imagine if this vulnerability existed in a user database.
With this API design, the responsibility for verifying user entitlements falls on the application. It's not good enough to check that a user has a valid session. The application needs to verify the user's name against a list of entitlements.
Laravel Broken Object Level Authorization
Let's look at BOLA in Laravel. We'll use an application that implements the API described above.
A Simple Laravel Rest API
Laravel makes building a CRUD API very straightforward.
Since we're using MySQL as the backend for storing comics, the model code couldn't be simpler.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Comic extends Model
{
use HasFactory;
}
Laravel makes basic authentication easy, too. By wrapping the calls to the controller with calls to Sanctum, it's easy to ensure that no one accesses the API without a valid session.
So, our routing entries for the comics API use the Sanctum middleware. Meanwhile, the login and register routes so you can use them to create sessions.
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->get('/comics', 'App\Http\Controllers\ComicController@index');
Route::middleware('auth:sanctum')->get('/comics/{comic}', 'App\Http\Controllers\ComicController@show');
Route::middleware('auth:sanctum')->post('comics', 'App\Http\Controllers\ComicController@store');
Route::middleware('auth:sanctum')->put('comics/{comic}', 'App\Http\Controllers\ComicController@update');
Route::middleware('auth:sanctum')->delete('comics/{comic}', 'App\Http\Controllers\ComicController@delete');
Route::post('login', [App\Http\Controllers\AuthController::class, 'login']);
Route::post('register', [App\Http\Controllers\AuthController::class, 'register']);
The comics controller uses the model to retrieve, update, create, and delete items in MySQL.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Comic;
use Illuminate\Support\Facades\Log;
class ComicController extends Controller
{
public function index()
{
return Comic::all();
}
public function show(Request $request, $id)
{
return Comic::find($id);
}
public function store(Request $request)
{
$comic = Comic::create($request->all());
return response()->json($comic, 201);
}
public function update(Request $request, $id)
{
$comic = Comic::findOrFail($id);
$comic->update($request->all());
return response()->json($comic, 200);
}
public function delete(Request $request, $id)
{
$Comic = Comic::findOrFail($id);
$Comic->delete();
return response()->json(null, 204);
}
}
Finally, we need a controller for creating new users and logging them in.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
class AuthController extends Controller
{
public function login(Request $request)
{
if(Auth::attempt(['email' => $request->email, 'password' => $request->password])){
$auth = Auth::user();
$success['token'] = $auth->createToken('LaravelSanctumAuth')->plainTextToken;
$success['name'] = $auth->name;
return $this->handleResponse($success, 'User logged-in!');
}
else{
return $this->handleError('Unauthorised.', ['error'=>'Unauthorised']);
}
}
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required',
'email' => 'required|email',
'password' => 'required',
'confirm_password' => 'required|same:password',
]);
$input = $request->all();
$input['password'] = bcrypt($input['password']);
$user = User::create($input);
$success['token'] = $user->createToken('LaravelSanctumAuth')->plainTextToken;
$success['name'] = $user->name;
return $this->handleResponse($success, 'User successfully registered!');
}
}
Testing the Service
So, let's take the new service for a spin.
A new user wants to buy some comics via the web. They try to list a comic via the API.
The GUI shouldn't let you get to this point, but since the API blocks unauthorized requests, no harm done.
This simple service is returning an Internal Server Error because we haven't wired all the proper statuses together yet. In production, you'd get a 401 instead of 500.
So, our new customer gets on the right path and creates a new login.
Now they have a login and a token, so they can look at comics!
The simple authorization we set up expects the token to be returned as a Bearer Token.
Success!
In a shopping app listing all items isn't an issue, especially of the API knows how to implement pagination to keep from blowing up your UI. But in an application that manages user data, this would be a problem.
Anyway, that Hulk comic looks expensive, and the title's wrong, too! Let's fix that.
Here's the update to the record:
Here's the PUT request. We're going to try to update a comic using a customer's bearer token.
The request succeeded. The server returned a 200 and echoed back the new value to us.
Let's double-check.
Uh-oh. We changed the inventory using a client's credentials. That's broken object level authorization.
Fixing Laravel BOLA
So how do we fix this?
We need to verify that users are entitled to make a request, not just that they have a valid session.
Laravel makes this easy. When Sanctum verifies the bearer token, it adds the user information to the auth object that's associated with the request. So, we can check the user name before processing the request. It's stored in auth('sanctum')->user().
Let's allow the sales to update comics so they can adjust the quantity when they sell one, and we'll allow the boss because they're the boss. If the user isn't one of these people, we'll return a 401.
public function update(Request $request, $id)
{
if (auth('sanctum')->user()->name == "theboss" || auth('sanctum')->user()->name == 'sales') {
$comic = Comic::findOrFail($id);
$comic->update($request->all());
return response()->json($comic, 200);
} else {
return response()->json(null, 401);
}
}
Let's test this with the new user's bearer token.
We got a 401 (Unauthorized) status back. It works!
Hardcoding values like user names is never a good idea. Ideally, we'd add this to the database schema, but we can add it to the configuration file for now.
So, let's add two arrays to the application configuration in config/app.php:
'administrators' => ['theboss'],
'sales' => ['sales'],
Now we can have more than one administrator or sales account.
Next, update the check to use the configuration values:
public function update(Request $request, $id)
{
if ( in_array(auth('sanctum')->user()->name, Config::get('app.administrators')) ||
in_array(auth('sanctum')->user()->name, Config::get('app.sales'))) {
$comic = Comic::findOrFail($id);
$comic->update($request->all());
return response()->json($comic, 200);
} else {
return response()->json(null, 401);
}
}
Before updating an item, the function checks the user name against both arrays. It only allows the request to pass if the requestor is a member of administrator or sales.
We can restrict deleting items to administrator now, too:
public function delete(Request $request, $id)
{
if (in_array(auth('sanctum')->user()->name, Config::get('app.administrators'))) {
$Comic = Comic::findOrFail($id);
$Comic->delete();
return response()->json(null, 204);
} else {
return response()->json(null, 401);
}
}
So, this app has simple object level authorization now. A better solution might ut the access levels in MySQL and maybe even return them as part of the Sanctum information. But, the major security hole is fixed, and the entitled users aren't hardcoded.
Wrapping up
We started by looking at broken object level authorization and how users can exploit it to steal or alter information they shouldn't have be able to. Then, we moved on to Laravel broken object authorization in a simple REST API. We saw how the vulnerability looks and applied a simple fix.
Now that you know how to address BOLA, you can build safer and better applications. But if you want to upgrade your security skills, StackHawk has the tools you need. Sign up for a free account and see how!
This post was written by Eric Goebelbecker. Eric has worked in the financial markets in New York City for 25 years, developing infrastructure for market data and financial information exchange (FIX) protocol networks. He loves to talk about what makes teams effective (or not so effective!).