How We Audited a High-Traffic Ruby Enterprise Stack on Linode and Mitigated Broken Object Level Authorization (BOLA) in API gateway endpoints
Understanding the Threat: Broken Object Level Authorization (BOLA)
Broken Object Level Authorization (BOLA), also known as Insecure Direct Object References (IDOR) in some contexts, is a critical security vulnerability where an application fails to properly enforce authorization checks on every object accessed by an authenticated user. In a high-traffic enterprise stack, particularly one with a complex API gateway layer, this can lead to unauthorized data access, modification, or deletion. Our audit focused on a Ruby-on-Rails application hosted on Linode, with a significant portion of its traffic routed through an API Gateway (we’ll use Kong as a representative example for configuration snippets, though the principles apply broadly).
The core problem arises when an API endpoint, intended to operate on a specific user’s resource (e.g., `/api/v1/users/:user_id/orders/:order_id`), allows an attacker to change the `:user_id` or `:order_id` to access or manipulate resources belonging to *another* user. This is often due to insufficient checks within the application logic or, more commonly in a gateway-centric architecture, a failure to propagate and enforce authorization policies at the object level within the gateway itself.
Audit Methodology: From Gateway to Application
Our audit followed a multi-layered approach:
- API Gateway Configuration Review: We started by examining the Kong API Gateway configuration. This involved scrutinizing authentication plugins, authorization policies, and request transformation rules. The goal was to identify any misconfigurations that might allow unauthenticated or improperly authenticated requests to reach backend services, or that might pass through sensitive user identifiers without proper validation.
- Traffic Analysis & Fuzzing: Using tools like Postman and custom scripts, we systematically tested API endpoints. We focused on parameters that represented resource identifiers (e.g., `id`, `user_id`, `account_id`). We attempted to access resources belonging to other users by manipulating these IDs.
- Application Code Review (Ruby on Rails): For endpoints identified as potentially vulnerable, we performed targeted code reviews within the Rails application. We looked for instances where `find_by` or similar methods were used without explicit authorization checks against the currently authenticated user’s ID.
- Database Schema & Access Control: While less common for BOLA, we briefly reviewed database access patterns to ensure that even if an attacker bypassed application-level checks, database-level permissions would prevent unauthorized data access.
API Gateway Configuration: Kong Example
In our setup, Kong acted as the primary ingress point. Authentication was handled by a JWT plugin, and authorization was intended to be enforced by a custom Lua plugin or by passing user context to the backend. The critical oversight was that the JWT plugin only validated the token’s signature and expiration, but did not inherently enforce object-level access. The user ID was extracted from the JWT payload and passed as a header (e.g., `X-User-ID`) to the backend Rails application.
Initial Kong Configuration Snippet (Illustrative)
Consider a simplified Kong service definition:
services:
- name: my-rails-app
url: http://rails-app-service:3000
routes:
- name: orders-route
paths:
- /api/v1/orders/*
plugins:
- name: jwt
config:
run_on: first_data
# ... other JWT config ...
- name: request-transformer
config:
add:
headers:
- X-User-ID: $jwt.claims.sub # Assuming 'sub' is the user ID claim
The vulnerability here is that the `request-transformer` plugin simply injects the user ID from the JWT into a header. The backend application *must* then use this `X-User-ID` header to validate that the requested `order_id` belongs to the authenticated `X-User-ID`. If the endpoint was `/api/v1/orders/:order_id`, and the application logic only fetched the order by `order_id` without checking `X-User-ID`, BOLA would occur.
Application-Level Vulnerabilities in Ruby on Rails
The Rails application was the ultimate arbiter of access. When the API gateway passed the `X-User-ID` header, the application was responsible for using it. A common pattern that leads to BOLA in Rails:
Vulnerable Controller Code
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
before_action :authenticate_user! # Assumes Devise or similar
def show
# VULNERABLE: Only checks if order_id exists, not if it belongs to the current user.
# The `current_user` is derived from the authentication mechanism (e.g., Devise).
@order = Order.find(params[:id])
# If the API Gateway injects X-User-ID, and we don't use it here,
# an attacker could manipulate the request to target another user's order.
# For example, if the gateway passes `X-User-ID: 123` and the current user is `456`,
# but the `show` action doesn't check `current_user.id` against `@order.user_id`,
# then `Order.find(params[:id])` might return an order belonging to user `123`
# if the attacker crafts the request to include `X-User-ID: 123` and a valid order ID.
render json: @order
end
# ... other actions
end
In this flawed example, `Order.find(params[:id])` retrieves an order solely based on its primary key. It completely ignores the context of the authenticated user. An attacker could potentially bypass authentication (if the gateway’s JWT was compromised or improperly configured) or, more likely, manipulate the `X-User-ID` header (if the gateway didn’t validate it against the authenticated user’s identity) and then exploit the application’s lack of authorization check.
Mitigation Strategy: Layered Security
Our mitigation involved strengthening security at both the API Gateway and the application layers.
1. API Gateway Enhancements (Kong)
The primary goal was to ensure that the gateway not only authenticated users but also enforced basic authorization rules, preventing requests from reaching the backend if they were clearly unauthorized at a high level. We introduced a custom Lua policy to validate the `X-User-ID` header against the authenticated user’s identity derived from the JWT.
Custom Kong Lua Plugin (Illustrative)
-- /usr/local/share/lua/5.1/kong/plugins/user-id-validator/handler.lua
local kong = require "kong"
local json = require "kong.tools.json"
local UserIDValidator = {}
function UserIDValidator:new()
local plugin = {
PRIORITY = 1, -- Ensure this runs after JWT authentication
name = "user-id-validator",
}
setmetatable(plugin, { __index = self })
return plugin
end
function UserIDValidator:access(conf)
local user_id_from_jwt = kong.request.get_jwt_claim("sub") -- Get user ID from JWT claim
local user_id_from_header = kong.request.get_header("X-User-ID")
if not user_id_from_jwt then
kong.log.err("JWT 'sub' claim missing.")
return kong.response.exit(401, json.encode({ message = "Unauthorized: Missing user identity." }))
end
if not user_id_from_header then
kong.log.err("X-User-ID header missing.")
return kong.response.exit(400, json.encode({ message = "Bad Request: X-User-ID header is required." }))
end
-- Crucial check: Ensure the user ID in the header matches the authenticated user's ID from JWT
if user_id_from_jwt ~= user_id_from_header then
kong.log.warn("User ID mismatch: JWT 'sub' (%s) does not match X-User-ID header (%s).", user_id_from_jwt, user_id_from_header)
return kong.response.exit(403, json.encode({ message = "Forbidden: User identity mismatch." }))
end
-- If checks pass, the request continues to the backend.
kong.log.info("User ID validation successful for user: %s", user_id_from_jwt)
end
return UserIDValidator
This Lua plugin, when configured on the relevant routes in Kong, intercepts requests *after* JWT authentication. It extracts the authenticated user ID from the JWT (`sub` claim) and compares it against the `X-User-ID` header. If they don’t match, or if either is missing, the request is rejected with a `401` or `403` status code before it even hits the Rails application.
Updated Kong Configuration
services:
- name: my-rails-app
url: http://rails-app-service:3000
routes:
- name: orders-route
paths:
- /api/v1/orders/*
plugins:
- name: jwt
config:
# ... JWT config ...
- name: request-transformer
config:
add:
headers:
- X-User-ID: $jwt.claims.sub
- name: user-id-validator # Our custom plugin
config: {} # No specific config needed for this simple version
This layer of defense prevents an attacker from simply spoofing the `X-User-ID` header. However, it doesn’t solve the problem if the Rails application *itself* doesn’t correctly use the validated `X-User-ID` for object-level authorization.
2. Application-Level Authorization Enforcement (Ruby on Rails)
The most robust solution is to ensure that every data access operation is authorized against the current user’s context. This means modifying the controller actions to explicitly check ownership.
Secured Controller Code
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
before_action :authenticate_user!
before_action :set_order, only: [:show, :update, :destroy] # Use a common finder method
before_action :authorize_order!, only: [:show, :update, :destroy] # Authorization check
def show
# @order is already set and authorized by before_actions
render json: @order
end
# ... other actions
private
def set_order
# Find the order by ID. This is safe IF authorize_order! is called afterwards.
# Alternatively, combine finding and authorization:
# @order = current_user.orders.find_by(id: params[:id])
# if @order.nil?
# render json: { error: "Order not found" }, status: :not_found
# end
# The approach below separates concerns for clarity in this example.
@order = Order.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Order not found" }, status: :not_found
end
def authorize_order!
# CRITICAL CHECK: Ensure the found order belongs to the current user.
unless @order && @order.user_id == current_user.id
# Log this attempt for security monitoring
Rails.logger.warn("Authorization failed: User #{current_user.id} attempted to access Order #{params[:id]}.")
render json: { error: "Unauthorized access to order" }, status: :forbidden
end
end
# If using `current_user.orders.find_by(...)` in set_order, this separate
# `authorize_order!` method might become redundant, but explicit checks are good.
end
In the secured version:
- `set_order` attempts to find the order by ID. It includes error handling for `RecordNotFound`.
- `authorize_order!` is the crucial step. It verifies that the `@order` object was successfully found *and* that its `user_id` attribute matches the `current_user.id`. If this check fails, a `403 Forbidden` response is returned.
- The `current_user` helper method (common in authentication gems like Devise) is assumed to correctly identify the logged-in user, typically by inspecting session cookies or authentication tokens passed from the gateway.
An even more idiomatic Rails approach is to scope the query directly:
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
before_action :authenticate_user!
before_action :set_and_authorize_order, only: [:show, :update, :destroy]
def show
render json: @order
end
private
def set_and_authorize_order
# Combines finding and authorization in one step.
# This is generally the most secure and efficient pattern.
@order = current_user.orders.find_by(id: params[:id])
unless @order
Rails.logger.warn("Authorization failed: User #{current_user.id} attempted to access Order #{params[:id]} (not found or not owned).")
render json: { error: "Order not found or unauthorized" }, status: :not_found # Or :forbidden depending on desired behavior
end
end
end
This consolidated approach ensures that an order is only retrieved if it belongs to the `current_user`. If `find_by` returns `nil`, it means either the order doesn’t exist or it doesn’t belong to the user, effectively preventing BOLA.
Testing and Verification
After implementing these changes, rigorous testing was essential:
- Automated Tests: We added RSpec or Minitest cases to specifically target BOLA scenarios. These tests would simulate requests from one user ID and attempt to access resources belonging to another, asserting that a `403 Forbidden` or `404 Not Found` is returned.
- Manual Penetration Testing: Our security team re-tested all API endpoints, focusing on resource identifiers and attempting to escalate privileges or access unauthorized data.
- Monitoring & Alerting: We configured our logging and monitoring systems (e.g., Datadog, ELK stack) to alert on `403` and `404` responses originating from authorization failures, especially those involving user context mismatches. This helps detect ongoing or new BOLA attempts.
Conclusion
Mitigating Broken Object Level Authorization requires a defense-in-depth strategy. Relying solely on the API Gateway or the application layer is insufficient. By implementing strict validation at the gateway to ensure user identity is correctly propagated and then enforcing granular authorization checks within the application code for every object access, we significantly hardened our Ruby enterprise stack against this common and dangerous vulnerability. The key takeaway is that authorization is not a one-time check; it must be applied consistently at every point where a resource is accessed or manipulated.