How We Audited a High-Traffic Ruby Enterprise Stack on AWS and Mitigated Broken Object Level Authorization (BOLA) in API gateway endpoints
Understanding the Threat: Broken Object Level Authorization (BOLA) in API Gateways
Our recent engagement involved auditing a high-traffic Ruby enterprise stack deployed on AWS. A critical focus area was identifying and mitigating Broken Object Level Authorization (BOLA) vulnerabilities, particularly within API Gateway endpoints. BOLA occurs when an application fails to properly enforce authorization checks on individual user-owned resources, allowing an attacker to access or manipulate resources they are not permitted to. In a microservices architecture, especially one fronted by an API Gateway, this can be a pervasive and dangerous flaw.
The architecture in question leveraged AWS API Gateway as the primary ingress point, routing requests to various backend Ruby microservices. Authentication was handled by AWS Cognito, with JWT tokens passed to API Gateway. The challenge was ensuring that each microservice, upon receiving a request, not only validated the JWT but also performed granular authorization checks against the specific resource ID present in the request path or body.
Audit Methodology: From Discovery to Exploitation
Our audit followed a structured methodology:
- Inventorying Endpoints: We began by cataloging all API Gateway endpoints and their corresponding backend services. This involved parsing API Gateway configurations and service discovery mechanisms.
- Traffic Interception and Analysis: Using tools like Burp Suite Pro and AWS VPC Flow Logs, we intercepted and analyzed traffic to understand request patterns, identify resource identifiers (e.g.,
/users/{userId}/orders/{orderId}), and observe authorization headers. - Manual Authorization Testing: For each endpoint, we systematically tested authorization by attempting to access resources belonging to other users. This involved manipulating the
userIdororderIdparameters in requests while authenticated as a specific user. - Automated Scanning (Limited Scope): While automated scanners can identify some BOLA patterns, they are often insufficient for complex, context-aware authorization logic. We used them as a supplementary tool for initial reconnaissance.
- Code Review: Targeted code reviews of the Ruby backend services focused on how resource IDs were extracted and how authorization checks were implemented (or, more critically, *not* implemented).
Identifying BOLA in API Gateway Proxied Endpoints
A common pattern we observed was the reliance on API Gateway’s authorizers (e.g., Lambda authorizers or Cognito authorizers) for authentication and coarse-grained authorization. However, these often lack the context to perform fine-grained object-level checks. The responsibility then falls to the backend service.
Consider an endpoint like GET /api/v1/orders/{orderId}. The API Gateway might validate the JWT and confirm the user is authenticated. However, it typically doesn’t know if the authenticated user is *supposed* to see that specific orderId. This check must happen in the Ruby service.
Example Scenario: Vulnerable Ruby Endpoint
Let’s look at a simplified, vulnerable Ruby on Rails controller action:
Vulnerable Rails Controller Snippet
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController << ApplicationController
before_action :authenticate_user! # Assumes this sets @current_user
def show
order_id = params[:id]
# !!! VULNERABLE: Directly fetching order without user ownership check !!!
@order = Order.find(order_id)
render json: @order
rescue ActiveRecord::RecordNotFound
render json: { error: "Order not found" }, status: :not_found
end
# ... other actions
end
end
end
In this snippet, the show action fetches an order by its ID but fails to verify if the @current_user actually owns that order. An attacker could simply change the orderId in the request to access another user’s order.
Mitigation Strategy: Implementing Robust Authorization Checks
The primary mitigation is to enforce authorization checks at the earliest possible point in the backend service, using the authenticated user’s identity. This involves ensuring that any operation on a resource includes a check against the owning user.
Securing the Rails Controller
Here’s the corrected version of the Rails controller action:
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController << ApplicationController
before_action :authenticate_user!
before_action :set_order, only: [:show, :update, :destroy] # Apply to relevant actions
before_action :authorize_order_owner!, only: [:show, :update, :destroy] # New authorization check
def show
render json: @order
rescue ActiveRecord::RecordNotFound
render json: { error: "Order not found" }, status: :not_found
end
# ... other actions
private
def set_order
order_id = params[:id]
# Fetch order, but ensure it's associated with the current user
@order = @current_user.orders.find_by(id: order_id) # Assuming User has_many :orders
# If Order.find(order_id) was used, we'd need a separate check:
# @order = Order.find(order_id)
# authorize_order_owner! # Call the specific authorization method
render json: { error: "Order not found or you do not have access" }, status: :not_found unless @order
end
def authorize_order_owner!
# This check is implicitly done by `set_order` if using `@current_user.orders.find_by(id: order_id)`
# If `set_order` fetched the order without user context, we'd add:
# unless @order.user_id == @current_user.id
# render json: { error: "Forbidden" }, status: :forbidden
# end
end
end
end
end
In the corrected version:
- The
set_ordermethod now attempts to find the order directly through the@current_user‘s association (@current_user.orders.find_by(id: order_id)). This is the most efficient and secure way, as it leverages the database to enforce ownership. - If the order is not found *or* does not belong to the current user,
find_bywill returnnil, and the subsequent check renders a “not found” error. This is a common pattern to avoid revealing whether a resource exists but is inaccessible. - A dedicated
authorize_order_owner!method is introduced for clarity, though in this specific implementation, the check is integrated intoset_order. This pattern is highly recommended for complex authorization rules.
Leveraging API Gateway for Enhanced Security (Beyond Basic Auth)
While backend services are the ultimate gatekeepers for object-level authorization, API Gateway can still play a role in defense-in-depth:
1. Custom Lambda Authorizers for Granular Checks
For certain scenarios, a Lambda authorizer can perform more sophisticated checks. If user roles and resource permissions are relatively static and can be efficiently queried, a Lambda authorizer can pre-emptively deny requests.
Example Lambda Authorizer (Node.js)
// lambda/authorizer.js
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
exports.handler = async (event) => {
const token = event.authorizationToken; // Assumes token is passed directly
const methodArn = event.methodArn;
const resourceParts = methodArn.split(':');
const apiGatewayArnTmp = resourceParts[5].split('/');
const httpMethod = apiGatewayArnTmp[0];
const resourcePath = apiGatewayArnTmp.slice(1).join('/'); // e.g., "users/123/orders/456"
// Basic token validation (replace with your actual JWT validation)
if (!token || !isValidToken(token)) {
return generatePolicy('user', 'Deny', methodArn);
}
// Extract user ID and potentially resource ID from token or path
const userId = getUserIdFromToken(token);
const resourceId = extractResourceIdFromPath(resourcePath, httpMethod); // Implement this logic
// --- Object-Level Authorization Check ---
// This is a simplified example. In reality, you'd query a DB or cache.
const isAuthorized = await checkResourceOwnership(userId, resourceId, resourcePath, httpMethod);
const effect = isAuthorized ? 'Allow' : 'Deny';
const policy = generatePolicy(userId, effect, methodArn);
console.log('Generated Policy:', JSON.stringify(policy));
return policy;
};
function isValidToken(token) {
// Implement robust JWT validation (signature, expiry, issuer, etc.)
return token === 'valid-token'; // Placeholder
}
function getUserIdFromToken(token) {
// Parse token to get user ID
return 'user-abc'; // Placeholder
}
function extractResourceIdFromPath(path, method) {
// Logic to extract resource ID from path based on HTTP method and path structure
// e.g., if path is "users/123/orders/456" and method is GET, extract "456" for order
if (path.startsWith('users/') && path.includes('/orders/')) {
const parts = path.split('/');
if (parts.length === 4) return parts[3]; // Assuming order ID is the 4th part
}
return null;
}
async function checkResourceOwnership(userId, resourceId, path, method) {
// --- THIS IS THE CRITICAL PART ---
// Query your database (e.g., DynamoDB, RDS) to check if userId owns resourceId
// Example: Check if order with resourceId belongs to userId
if (resourceId && path.includes('/orders/')) {
const params = {
TableName: 'OrdersTable', // Your DynamoDB table name
Key: { 'order_id': resourceId },
ProjectionExpression: 'user_id'
};
try {
const data = await dynamodb.get(params).promise();
if (data.Item && data.Item.user_id === userId) {
return true; // User owns the order
}
} catch (error) {
console.error("DynamoDB query failed:", error);
return false; // Fail closed
}
}
// Add checks for other resource types and methods
return false; // Default to deny if not explicitly allowed
}
function generatePolicy(principalId, effect, resource) {
return {
principalId: principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource
}]
}
};
}
Caveats:
- Lambda authorizers add latency. Complex checks can significantly impact API response times.
- They increase operational complexity and cost.
- Maintaining the authorization logic in both the Lambda authorizer and the backend service can lead to inconsistencies if not managed carefully.
2. API Gateway Request Validation
While not directly for BOLA, API Gateway’s request validation can prevent malformed requests from reaching the backend, indirectly aiding security. Ensure path parameters and request bodies are validated against expected schemas.
Post-Mitigation Verification and Continuous Monitoring
After implementing the backend fixes, we performed a re-audit, specifically targeting the previously vulnerable endpoints. We used the same techniques (traffic interception, manual testing) to confirm that unauthorized access attempts were now correctly blocked.
Continuous monitoring is crucial. This includes:
- Logging: Ensure backend services log all authorization failures with sufficient detail (user ID, resource ID, timestamp, endpoint).
- Alerting: Set up alerts for a high volume of authorization failures, which could indicate an ongoing attack attempt. AWS CloudWatch Alarms on log metrics are effective here.
- Regular Audits: Schedule periodic security audits, including penetration testing, to catch regressions or new vulnerabilities.
- Code Review Practices: Integrate security reviews into the CI/CD pipeline, focusing on authorization logic for new features.
Conclusion: Defense-in-Depth for Object-Level Security
Broken Object Level Authorization is a critical vulnerability that can have severe consequences. For high-traffic enterprise stacks on AWS, a layered security approach is essential. While API Gateway provides a valuable first line of defense for authentication and coarse-grained authorization, the ultimate responsibility for enforcing object-level authorization lies with the backend services. By implementing rigorous, context-aware authorization checks within the Ruby application code and complementing this with robust logging and monitoring, organizations can significantly reduce their BOLA risk surface.