Broken object level authorization (BOLA) is a serious API problem that can result in attackers deleting, altering, or misusing data. It happens when an API allows a user to access and alter data they shouldn't be able to.
Unlike other security issues, BOLA doesn't necessarily require an attacker to hack their way into a system. If they're aware of the problem, they can create a user and access data without any extra work.
This article will discuss broken object level authorization (BOLA) problems in Rust applications. We'll look at an API example that takes advantage of Rusts' Tide and Diesel crates to avoid this serious security issue, then we'll talk about how to apply these concepts to your code.
Broken Object Level Authorization
When an application doesn't check a user's entitlements before allowing access to a resource, it suffers from broken object level authorization. So, users can retrieve, modify, create and even delete resources because the application doesn't verify user access at the individual item level.
BOLA is a design deficiency. It's easy to enable an application with authentication because so many third-party services and libraries do the heavy lifting for you. The responsibility for ensuring a user has access to the resource they want and the rights to perform the action they need falls to the application code.
Attackers take advantage of BOLA problems quickly. They are very easy to exploit since they only need an unprivileged user and an API tool like cURL or Wget.
A Broken Object Level Authorization Example
RESTful API Implementation
Let's take a look at a simple API for managing blog posts.
The API represents an Article with a JSON object that looks like this:
"article": {
"body": "Hi there! This is an article",
"description": "An Introductory Post",
"title": "Hello There New User",
"slug": "hello"
"tagList": [
"tutorial",
"intro post"
]
}
The client doesn't supply the slug for a new article. When the server creates the article, it returns the slug and acts as the article identifier.
The application offers typical CRUD endpoints, although the endpoints include the slug for operations on an article that exists:
GET /articles/ with no argument retrieves a list of all articles.
POST /articles/ with a JSON record to add a new article.
PUT /articles/{slug} with a JSON record updates an existing article.
GET /articles/{slug} to retrieve an article by slug.
DELETE /articles/{slug} deletes an article by slug.
BOLA API Issue
This is part of an API for a blog site. Users post articles. As time passes, they may decide to update or even delete them. But since posts are associated with users, it's important that only the user that created an article can alter or remove them.
So at a minimum, the website should provide object-level authorization like this:
Users can only read posts without logging in.
Authenticated users may add posts.
Only the user that created a post can update it.
Only the user that created the post can delete it.
A more sophisticated API would add elevated object level authorization for an administrator user, but that's beyond the scope of this tutorial.
Regardless of the details, merely checking that a user has a valid session isn't adequate. The application has to verify that only users can modify their data.
Rust (un)Broken Object Level Authorization
Sample Rust RESTful API
Enforcing proper object level authorization is up to the application, and developers design the best implementations with data ownership and access in mind. We're going to use code from the realworld-tide sample code on GitHub. It uses the Tide crate for serving the web API and Diesel for the database. It's designed from the ground up with proper object level authorization. In other words, it doesn't suffer from BOLA. Let's see how.
Let's start with the routes for managing articles. I've snipped out the rest of the API, so the code is easier to read:
pub fn add_routes<R: Repository + Send + Sync>(mut api: Server<Context<R>>) -> Server<Context<R>> {
(snip)
api.at("/api/articles")
.get(|req| async move { result_to_response(crate::articles::list_articles(req).await) })
.post(|req| async move { result_to_response(crate::articles::insert_article(req).await) });
(snip)
api.at("/api/articles/:slug")
.get(|req| async move { result_to_response(crate::articles::get_article(req).await) })
.put(|req| async move { result_to_response(crate::articles::update_article(req).await) })
.delete(
|req| async move { result_to_response(crate::articles::delete_article(req).await) },
);
(snip)
}
Each route calls a function in the articles crate with the requested information as the sole argument.
First, here's the function for creating a new article:
pub async fn insert_article<R: 'static + Repository + Sync + Send>(
mut cx: tide::Request<Context<R>>,
) -> Result<Response, ErrorResponse> {
let request: Request = cx
.body_json()
.await
.map_err(|e| Response::new(400).body_string(e.to_string()))?;
let author_id = cx.get_claims().map_err(|_| Response::new(401))?.user_id();
let repository = &cx.state().repository;
let author = repository.get_user_by_id(author_id)?;
let published_article = author.publish(request.article.into(), repository)?;
Ok(Response::new(200)
.body_json(&ArticleResponse::from(published_article))
.unwrap())
}
First, on line #4, this code extracts the article body from the Tide request context.
Then line #8 gets the authenticated user id via the application state, which it also retrieves from the request context. If the requestor isn't authenticated, they receive a 401 error.
Finally, the code retrieves the repository and then retrieves an author object and calls publish. Why not call the repository directly?
A User-Centric Design
Here's the publish method. It's a member of a User class. It's a wrapper for publish_article in the repository. Publish calls the repository with a reference to itself:
impl User {
pub fn publish(
&self,
draft: ArticleContent,
repository: &impl Repository,
) -> Result<Article, PublishArticleError> {
repository.publish_article(draft, &self)
}
(snip)
}
This method exists for one reason: to ensure that the article has the correct user associated with it. This association makes sense for a blog site. Articles have authors.
But instead of making the author a text field with a name in it, this design takes it a step further by centering operations on articles on the users.
Update an Article with Proper Authorization
Next, let's trace through what happens when a user tries to update an article.
We start here:
pub async fn update_article<R: 'static + Repository + Sync + Send>(
mut cx: tide::Request<Context<R>>,
) -> Result<Response, ErrorResponse> {
let request: Request = cx
.body_json()
.await
.map_err(|e| Response::new(400).body_string(e.to_string()))?;
let slug: String = cx.param("slug").map_err(|_| Response::new(401))?;
let user_id = cx.get_claims().map_err(|_| Response::new(401))?.user_id();
let repository = &cx.state().repository;
let article = repository.get_article_by_slug(&slug)?;
let user = repository.get_user_by_id(user_id)?;
let updated_article = user.update_article(article, request.into(), repository)?;
let response: ArticleResponse = repository.get_article_view(&user, updated_article)?.into();
Ok(Response::new(200).body_json(&response).unwrap())
}
The first few lines are identical to insert_article: get the article body, get the user, and the repository.
Then on line #12, we retrieve the original article before passing it to the User class on line #14.
So, let's take a look at what happens there:
pub fn update_article(
&self,
article: Article,
update: ArticleUpdate,
repository: &impl Repository,
) -> Result<Article, ChangeArticleError> {
if article.author.username != self.profile.username {
return Err(ChangeArticleError::Forbidden {
slug: article.slug,
user_id: self.id,
});
}
let updated_article = repository.update_article(article, update)?;
Ok(updated_article)
}
Lines #7 - #12 are where the code enforces object level authorization. Before modifying the object, the code ensures that the authenticated user owns the article.
Before we move on, let's verify that the code enforces proper authorizations for deleting articles:
pub fn delete(
&self,
article: Article,
repository: &impl Repository,
) -> Result<(), ChangeArticleError> {
// You can only delete your own articles
if article.author.username != self.profile.username {
return Err(ChangeArticleError::Forbidden {
slug: article.slug,
user_id: self.id,
});
}
Ok(repository.delete_article(&article)?)
}
There's the same check! This code enforces proper object level authorization for blog posts.
Fixing Broken Object Level Authorization
So that's what code that's designed to avoid BOLA looks like. How do you fix existing code?
We can derive basic rules from the code above.
The application must store all objects with an owner. The owner may be a user, a group, or a role.
Users must authenticate before they can alter an object.
Before they alter an object, the application must check that authenticated users own the object or are members of a group or role that has "write" access to the object.
How difficult it is to retrofit this into legacy code will vary.
Do your objects already have a notion of ownership? If not, that's where you need to start.
But, if your objects already have owners, is it easy to access this info from the modify and delete functions? If it is, your work is nearly done! Otherwise, figure out how to expose this to the rest of the code.
Avoiding Rust BOLA
In this post, we covered BOLA in Rust code and how to avoid it. We discussed what BOLA is and how it can lead to serious data exposures. Then we looked at a well-designed Rust application designed to avoid the problem. Finally, we discussed how you can apply these concepts to legacy code.
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!).