According to the OWASP Top 10 for 2021, the most common vulnerability in web APIs is broken access control. This security issue encompasses several problems, but the largest is broken object level authorization. This problem occurs when an application doesn't properly verify user permissions before providing access to a privileged resource. It's a serious security leak that many applications fail to address.
We're going to look at examples of broken object level authorization (BOLA) issues in golang applications and how you can fix and prevent them.
Broken Object Level Authorization
Object level authorization ensures that users can only access the objects they have the proper entitlements for. While third-party applications and services usually implement authentication, applications need to take care of authorizing access to their internal data. BOLA is when the app fails to do that. Depending on the nature of the problem, it allows users to read, alter, delete, or create data that they shouldn't have access to.
Attackers will quickly exploit a newly discovered BOLA issue in a web API since it's possible with a few simple scripts. So, BOLA means the likelihood of data loss or compromise is very high.
Let's look at an example.
A BOLA Example
Think of a typical Create Read Update Delete (CRUD) API for managing website users.
A user looks like this:
{
"id":"1",
"lastname":"Doe",
"firstname":"John",
"dept":"Janitorial",
"email":"jdoe@demo.com"
}
It has five endpoints:
POST /users/ with a JSON payload to add a user.
GET /users/{ID} to retrieve a user.
GET /users to retrieve a list of all users.
PUT /users/{ID} updates a user.
DELETE /users/{ID} deletes a user.
This is a standard implementation for a CRUD API. The potential problem is that you can retrieve, update, and delete a user if you know their ID.
If the API only checks if a user is valid and authenticated, but fails to confirm if they should be able to access the user ID, it has a BOLA vulnerability. Worse yet, if you have BOLA and the object IDs are sequential, an attacker can exploit the vulnerability and access all your objects.
Golang Broken Object Level Authorization
Let's look at the above example implemented as a golang web service. We'll see the broken object level authorization in action, and then we'll fix it.
This service uses Gorilla to route API requests, so the main function maps requests to handlers and sets up a listener.
func main() {
r := mux.NewRouter()
usersR := r.PathPrefix("/users").Subrouter()
usersR.Path("").Methods(http.MethodGet).HandlerFunc(getAllUsers)
usersR.Path("").Methods(http.MethodPost).HandlerFunc(createUser)
usersR.Path("/{id}").Methods(http.MethodGet).HandlerFunc(getUserByID)
usersR.Path("/{id}").Methods(http.MethodPut).HandlerFunc(updateUser)
usersR.Path("/{id}").Methods(http.MethodDelete).HandlerFunc(deleteUser)
fmt.Println("Start listening")
fmt.Println(http.ListenAndServe(":8080", r))
}
So we can keep our focus on golang broken object level authorization, we'll take a few shortcuts in the example code.
Instead of examing the code to create, verify, and manage a secure user session, we'll use a single mock to confirm that the current session is valid.
Also, instead of looking at SQL or NoSQL code, we'll use mocks to get and set user information.
View a User
So, here's the getUserById handler.
func getUserByID(w http.ResponseWriter, r *http.Request) {
valid, err := checkSession(r)
if valid == false {
fmt.Println(err)
http.Error(w, "Invalid user session!", http.StatusMethodNotAllowed)
return
}
id := mux.Vars(r)["id"]
u, err := getUserFromStore(id)
if err != nil {
http.Error(w, "Error retrieving user", http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(&u); err != nil {
fmt.Println(err)
http.Error(w, "Error encoding response object", http.StatusInternalServerError)
}
}
So we can view the BOLA exploit with cURL, we'll use a single header to simulate a valid session.
egoebelbecker@genosha-2 ~ % curl -H "Session_ID: rekcebleboeg" localhost:8080/users/1
{"id":"1","lastname":"Doe","firstname":"John","dept":"","email":"jdoe@demo.com"}
Since this GET handler only verifies that the requesting user has a valid session, anyone capable of creating a session can request a user by Id.
Since we've described this bug twice now, and we're looking at code that calls a method named checkSession(), the bug seems obvious. But that's not always the case, and it's a common design mistake. Many APIs are designed to be shared between a web GUI and a mobile app. The apps implicitly enforce object-level authorization when they lack a GUI element that lets you see someone else's information. Sometimes they're even structured so only one app can perform some functions.
But an attacker will view the web source code and reverse engineer the API, probably in minutes. This API doesn't check for proper access and has sequential user ids. It's in bad shape.
View All Users
So let's imagine that administrative users have a different web interface. They can view all users.
func getAllUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
valid, err := checkSession(r)
if valid == false {
fmt.Println(err)
http.Error(w, "Invalid user session!", http.StatusMethodNotAllowed)
return
}
var users []User
for key := range store.Keys(nil) {
u, err := getUserFromStore(key)
if err != nil {
http.Error(w, "Error retrieving user", http.StatusInternalServerError)
return
}
users = append(users, u)
}
if err := json.NewEncoder(w).Encode(users); err != nil {
fmt.Println(err)
http.Error(w, "Error encoding response object", http.StatusInternalServerError)
}
}
As a result, an attacker doesn't need to use sequential ids to grab the entire user database.
Here's that request. (Some formatting added.)
egoebelbecker@genosha-2 ~ % curl -H "Session_ID: rekcebleboeg" localhost:8080/users
[{"id":"1","lastname":"Doe","firstname":"John","dept":"HR","email":"jdoe@demo.com"},
{"id":"2","lastname":"Smith","firstname":"Jane","dept":"Dev","email":"jsmith@demo.com"},
{"id":"3","lastname":"Neuman","firstname":"Alfred E.","dept":"CEO","email":"whatme@worry.com"},
{"id":"4","lastname":"Goebelbecker","firstname":"Eric","dept":"Janitorial","email":"noreply@demo.com"}]
Here again, the application designers were relying on limitations in the client applications to stop users from seeing information they shouldn't.
Fixing Golang BOLA
So how do we fix these problems? There are several different approaches. Let's look at each one in brief.
The Wrong Answer
One answer is to remove the user Id from the API URLs.
So the five endpoints might look like this:
POST /users/ with a JSON payload to add a user.
GET /users/ to retrieve a user with a Header or JSON payload that contains the Id.
GET /users to retrieve a list of all users.
PUT /users/ updates a user.
DELETE /users/ deletes a user with a Header or JSON payload that contains the Id.
This only changes two endpoints and leaves all of the vulnerabilities in place. It might make it harder for an experienced hacker to exploit the API. It probably wouldn't phase a skilled attacker after a few minutes of experimenting with cURL.
There are better solutions.
Add Object Level Authorizations
The best and only effective solution is to add code to verify that users have access to the object they want to create, read, update, or delete.
The sample app already has a method to verify that a session is valid. To observe the single responsibility principle, let's add a new one to check if users can do what they're asking.
There are many ways to do this. Some are better than others, and some fit in a short blog post about BOLA and aren't production code.
First, let's define what a user wants to do and whether or not they can do it. Enums work well for this.
Defining Actions and Authorization
type Action int64
const (
Read Action = 0
Write = 1
Update = 2
Delete = 3
Create = 4
List = 5
)
type Authorization int64
const (
Denied Authorization = 0
Allowed = 1
)
Now, we can write a method that asks if a user can do what they're asking.
func checkAuthorization(requester string, action Action, target string) (auth Authorization) {
switch action {
case Read:
if requester == target || requester == admin {
return Allowed
}
break;
case Create:
case Update:
case Delete:
case Write:
case List:
if requester == admin {
return Allowed
}
}
return Denied
}
This is a bare-bones example of object level authorization. The method allows users to read their information and only allows an admin user to perform other operations. But, since we used an enum for the requested actions, it would be easy to add different levels of entitlements for different actions. So, a department head could modify their team members' records, or a human resources team could add address information, etc.
Adding It to the API
Let's look at the new getUserbyID:
func getUserByID(w http.ResponseWriter, r *http.Request) {
valid, user, err := checkSession(r)
if valid == false {
fmt.Println(err)
http.Error(w, "Invalid user session!", http.StatusMethodNotAllowed)
return
}
id := mux.Vars(r)["id"]
var auth = checkAuthorization(user, Read, id)
if auth == Allowed {
u, err := getUserFromStore(id)
if err != nil {
http.Error(w, "Error retrieving user", http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(&u); err != nil {
fmt.Println(err)
http.Error(w, "Error encoding response object", http.StatusInternalServerError)
}
} else {
http.Error(w, "Not permitted", http.StatusMethodNotAllowed)
return
}
}
We had to modify checkSession to return a user Id so we could use it to check permissions. If it passes, the method proceeds as before. If not, it returns an error to the requesting application.
Now, getAllusers looks like this:
func getAllUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
valid, user, err := checkSession(r)
if valid == false {
fmt.Println(err)
http.Error(w, "Invalid user session!", http.StatusMethodNotAllowed)
return
}
var auth = checkAuthorization(user, List, "")
if auth == Allowed {
var users []User
for key := range store.Keys(nil) {
u, err := getUserFromStore(key)
if err != nil {
http.Error(w, "Error retrieving user", http.StatusInternalServerError)
return
}
users = append(users, u)
}
if err := json.NewEncoder(w).Encode(users); err != nil {
fmt.Println(err)
http.Error(w, "Error encoding response object", http.StatusInternalServerError)
}
} else {
http.Error(w, "Not permitted", http.StatusMethodNotAllowed)
return
}
}
We don't need to pass a target user to checkAuthorization since we're trying to use the List action.
We'd add checkAuthorization to the other three methods in this app to finish the job, and we have basic object level authorization. Over time it could grow into a more sophisticated example with multiple layers of entitlement instance of a coarse, role-based system.
Extra Credit
While we've patched the big holes in this simple API, we could take one more step to make it a bit more robust: change the user Ids from numerals to a non-sequential format that's harder to guess, like UUIDs. While this doesn't address any security problems in the code, it does make the API a less attractive target.
Wrapping up
We've looked at broken object level authorization and how it exposes APIs to serious security problems. We also looked at an example in golang and how easy a BOLA is to exploit. We addressed the issue with object level authorization code, and then we finished up with another recommendation to help foil simple attacks.
StackHawk has tools to help you find these issues and address them in your code. 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!).