How We Audited a High-Traffic Ruby Enterprise Stack on OVH and Mitigated Broken Object Level Authorization (BOLA) in API gateway endpoints
Understanding the Threat: Broken Object Level Authorization (BOLA)
Broken Object Level Authorization (BOLA) is a critical security vulnerability where an authenticated user can access, modify, or delete resources they are not authorized to interact with. In a high-traffic enterprise environment, particularly one leveraging a microservices architecture and an API gateway, BOLA can have devastating consequences, ranging from data breaches to service disruptions. Our recent audit of a Ruby-based enterprise stack hosted on OVH revealed several instances of BOLA, primarily stemming from insufficient authorization checks at the API gateway level and within individual microservices.
Audit Methodology: From OVH Infrastructure to API Endpoints
Our audit began with a comprehensive review of the OVH infrastructure, focusing on network segmentation, firewall rules, and access control mechanisms. While the OVH environment itself was robust, the primary attack surface was identified as the API gateway, which served as the single entry point for all client requests to our Ruby microservices. We employed a multi-pronged approach:
- Infrastructure Scan: Utilized OVH’s native tools and third-party vulnerability scanners to identify misconfigurations in load balancers, firewalls, and server access.
- API Gateway Analysis: Deep-dived into the API gateway’s configuration (we were using Kong at the time) to scrutinize authentication and authorization plugins, rate limiting, and request routing logic.
- Code Review (Ruby Microservices): Performed static and dynamic analysis of critical Ruby microservices, focusing on endpoints that handled sensitive data or performed state-changing operations.
- Penetration Testing: Conducted targeted penetration tests simulating various user roles and attack vectors to actively exploit potential BOLA vulnerabilities.
Identifying BOLA in the API Gateway Layer
The API gateway is the first line of defense. If authorization checks are weak here, even strong authentication is rendered moot. We found that while our API gateway (Kong) was configured to authenticate users via JWTs, the authorization logic for specific resource access was often delegated entirely to the downstream microservices. This created a gap where a user with a valid JWT could potentially craft requests to access resources belonging to other users if the microservice didn’t perform its own rigorous checks.
A common pattern we observed was the use of generic endpoints like /users/{user_id}/orders. The gateway would authenticate the JWT, extract the `user_id` from the token, and pass the request to the orders microservice. If the orders microservice then blindly used the `user_id` from the JWT to fetch orders without verifying if the authenticated user *actually owned* that `user_id`, BOLA would occur.
Example: Kong Gateway Configuration Gap
Consider a simplified Kong configuration snippet. The JWT validation plugin is active, but it doesn’t enforce object-level ownership:
# kong.conf (simplified) plugins = jwt-authenticator, acl, rate-limiting # JWT Authenticator configuration (example) jwt_authenticator.anonymous = false jwt_authenticator.keys = ... jwt_authenticator.header_name = Authorization jwt_authenticator.algorithm = RS256 # ACL plugin configuration (example) acl.allow_any_authenticated = true # This is the problematic setting for BOLA
In this scenario, the acl.allow_any_authenticated = true setting, while convenient, bypasses granular access control at the gateway. The responsibility is pushed entirely to the backend services.
Mitigation Strategy 1: Enhancing API Gateway Authorization
The most effective mitigation is to implement authorization checks as early as possible, ideally at the API gateway. For Kong, this involves leveraging custom plugins or more sophisticated configurations. We opted for a combination of Kong’s built-in ACLs (configured more restrictively) and a custom Lua plugin for fine-grained checks.
Implementing Custom Lua Plugin for BOLA Checks
We developed a custom Lua plugin for Kong that intercepts requests *after* JWT authentication but *before* routing to the upstream service. This plugin inspects the request path and parameters, compares the authenticated user’s ID (extracted from the JWT claims) with the resource owner ID specified in the request, and denies the request if they don’t match.
-- /usr/local/share/lua/5.1/kong/plugins/bola-checker/handler.lua
local log = require "kong.tools.log"
local http = require "kong.http"
local json = require "kong.tools.json"
local BolaChecker = {}
-- Mapping of resource types to their corresponding JWT claim/request parameter for owner ID
-- This needs to be dynamically managed or configured.
local resource_owner_mapping = {
orders = { jwt_claim = "user_id", path_param_index = 2 }, -- e.g., /users/{user_id}/orders/{order_id}
accounts = { jwt_claim = "account_id", path_param_index = 1 }, -- e.g., /accounts/{account_id}/details
-- Add more resource types as needed
}
function BolaChecker.new()
return BolaChecker
end
function BolaChecker:access(conf)
local req = ngx.req
local uri = req.get_uri_args()
local headers = req.get_headers()
local jwt_token = headers["authorization"] or headers["Authorization"]
if not jwt_token or not jwt_token:match("^Bearer ") then
return ngx.exit(401) -- Unauthorized: No JWT
end
-- Assuming JWT is validated by another plugin and claims are available in ngx.ctx
local claims = ngx.ctx.jwt_claims
if not claims then
log.err("JWT claims not found in ngx.ctx")
return ngx.exit(500) -- Internal Server Error
end
local path_parts = ngx.req.get_path_parts()
-- path_parts might look like: {"users", "123", "orders", "456"}
for resource_type, mapping in pairs(resource_owner_mapping) do
-- Simple check: if the path contains the resource type and has enough parts
for i = 1, #path_parts do
if path_parts[i] == resource_type then
if #path_parts >= mapping.path_param_index + 1 then -- Ensure parameter exists
local requested_owner_id = path_parts[mapping.path_param_index]
local authenticated_user_id = claims[mapping.jwt_claim]
if not requested_owner_id or not authenticated_user_id then
log.warn("Missing owner ID or authenticated user ID for resource: ", resource_type)
return ngx.exit(400) -- Bad Request
end
if requested_owner_id ~= authenticated_user_id then
log.warn("BOLA detected: User ", authenticated_user_id, " attempting to access resource of ", requested_owner_id)
return ngx.exit(403) -- Forbidden
end
-- If we found a match and it's authorized, we can break this inner loop
-- and continue to the next request phase.
goto continue_next_phase
end
end
end
end
::continue_next_phase::
return ngx.OK
end
return BolaChecker
To deploy this, you would typically compile it into Kong and then configure a route to use this plugin. The resource_owner_mapping would need to be carefully defined to match your API’s URL structure and the claims present in your JWTs. This requires a deep understanding of your API’s resource hierarchy and how user identifiers are represented.
Mitigation Strategy 2: Robust Authorization in Ruby Microservices
While offloading authorization to the gateway is ideal, it’s crucial that microservices also implement their own authorization checks. This provides a defense-in-depth strategy. If the gateway is compromised or a direct service-to-service call bypasses the gateway, the microservice must still protect its resources.
Example: Ruby on Rails Controller Logic
In a typical Rails application, authorization checks should be performed within controllers or dedicated policy objects.
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
before_action :authenticate_user!
before_action :set_order, only: [:show, :update, :destroy]
before_action :authorize_order_owner!, only: [:show, :update, :destroy]
def show
# If we reach here, the order is authorized for the current user
render json: @order
end
# ... other actions
private
def set_order
# Fetch the order, potentially using a more specific query if available
# Example: If user_id is available from JWT claims in `current_user.id`
@order = Order.where(user_id: current_user.id).find_by(id: params[:id])
render json: { error: "Order not found" }, status: :not_found unless @order
end
def authorize_order_owner!
# This check is redundant if set_order already filters by user_id,
# but serves as an explicit safeguard.
unless @order.user_id == current_user.id
render json: { error: "Unauthorized access to this order" }, status: :forbidden
end
end
# Assuming `current_user` is set by `authenticate_user!`
# and `authenticate_user!` populates `current_user` with the authenticated user's details,
# including their ID, from the JWT claims.
end
In this Rails example, set_order is modified to *only* fetch orders belonging to the `current_user`. This is a crucial BOLA mitigation. The authorize_order_owner! is a belt-and-suspenders approach, ensuring that even if set_order had a bug, this explicit check would catch it. The key is ensuring that `current_user` is correctly populated with the authenticated user’s identity and that all resource fetches are scoped by this identity.
Testing and Validation
Post-implementation, rigorous testing is paramount. We used a combination of automated tests and manual verification:
- Automated API Tests: Developed integration tests using tools like Postman or custom scripts (e.g., Python with
requests) to simulate requests from different user roles, attempting to access resources they shouldn’t. - Manual Penetration Testing: Engaged our security team to perform black-box and grey-box testing, actively trying to bypass the implemented controls.
- Log Analysis: Monitored API gateway logs and microservice application logs for any denied requests or suspicious activity patterns that might indicate attempted BOLA exploits.
Example: Python Script for BOLA Testing
import requests
import json
BASE_URL = "https://api.your-enterprise.com"
USER_A_TOKEN = "eyJhbGciOiJSUzI1Ni..." # JWT for User A
USER_B_TOKEN = "eyJhbGciOiJSUzI1Ni..." # JWT for User B
USER_A_ORDER_ID = "order_123"
USER_B_ORDER_ID = "order_456"
def get_order(user_token, order_id):
headers = {
"Authorization": f"Bearer {user_token}",
"Content-Type": "application/json"
}
try:
response = requests.get(f"{BASE_URL}/api/v1/orders/{order_id}", headers=headers)
return response
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None
# Test Case 1: User A accessing their own order (should succeed)
print(f"--- Testing User A accessing Order {USER_A_ORDER_ID} ---")
response_a_own = get_order(USER_A_TOKEN, USER_A_ORDER_ID)
if response_a_own:
print(f"Status Code: {response_a_own.status_code}")
if response_a_own.status_code == 200:
print("Success: User A accessed their own order.")
else:
print("Failure: User A could not access their own order.")
else:
print("Request failed.")
print("\n" + "="*30 + "\n")
# Test Case 2: User A accessing User B's order (should fail with 403 Forbidden)
print(f"--- Testing User A accessing Order {USER_B_ORDER_ID} ---")
response_a_other = get_order(USER_A_TOKEN, USER_B_ORDER_ID)
if response_a_other:
print(f"Status Code: {response_a_other.status_code}")
if response_a_other.status_code == 403:
print("Success: User A was correctly denied access to User B's order.")
elif response_a_other.status_code == 404:
print("Warning: User A received 404 instead of 403. This might indicate information leakage.")
else:
print(f"Failure: User A unexpectedly accessed User B's order (Status: {response_a_other.status_code}).")
else:
print("Request failed.")
print("\n" + "="*30 + "\n")
# Test Case 3: User B accessing User A's order (should fail with 403 Forbidden)
print(f"--- Testing User B accessing Order {USER_A_ORDER_ID} ---")
response_b_other = get_order(USER_B_TOKEN, USER_A_ORDER_ID)
if response_b_other:
print(f"Status Code: {response_b_other.status_code}")
if response_b_other.status_code == 403:
print("Success: User B was correctly denied access to User A's order.")
elif response_b_other.status_code == 404:
print("Warning: User B received 404 instead of 403. This might indicate information leakage.")
else:
print(f"Failure: User B unexpectedly accessed User A's order (Status: {response_b_other.status_code}).")
else:
print("Request failed.")
This Python script automates the process of verifying that a user can only access their own resources. It’s crucial to have a comprehensive suite of such tests covering all sensitive endpoints and resource types.
Conclusion: A Layered Defense Against BOLA
Auditing and securing a high-traffic enterprise stack on a platform like OVH requires a meticulous approach. Broken Object Level Authorization is a pervasive threat that can be mitigated effectively through a layered security strategy. By enhancing the API gateway’s authorization capabilities with custom logic and ensuring that individual microservices rigorously enforce ownership checks, we significantly reduced our attack surface. Continuous monitoring, automated testing, and regular security audits are essential to maintain a strong security posture against evolving threats.