How We Audited a High-Traffic Laravel Enterprise Stack on AWS and Mitigated Broken Object Level Authorization (BOLA) in API gateway endpoints
Auditing a High-Traffic Laravel Enterprise Stack on AWS
Our engagement involved a critical audit of a high-traffic Laravel enterprise application hosted on AWS. The primary concern was the potential for Broken Object Level Authorization (BOLA) vulnerabilities within its API Gateway-exposed endpoints. This class of vulnerability allows an attacker to access or modify resources they are not authorized to, often by manipulating object identifiers in API requests.
The stack comprised a multi-tenant Laravel application, an AWS API Gateway acting as the primary ingress, an RDS PostgreSQL database, and various other AWS services like S3 and ElastiCache. The sheer volume of requests and the complexity of the multi-tenant architecture presented significant challenges for a thorough security review.
Methodology: Reconnaissance and Vulnerability Identification
Our audit began with an in-depth reconnaissance phase. This involved:
- Mapping all API endpoints exposed through AWS API Gateway.
- Analyzing request/response patterns for common parameters that could represent object identifiers (e.g.,
id,user_id,tenant_id,resource_uuid). - Identifying authentication and authorization mechanisms in place, particularly how user context and tenant context were propagated from API Gateway to the Laravel application.
- Reviewing AWS API Gateway configurations for authorization settings, request validation, and integration types.
We employed a combination of automated tools and manual testing. For automated scanning, tools like Postman with its scripting capabilities and custom Python scripts leveraging libraries like requests were instrumental in sending a high volume of crafted requests. Manual inspection of traffic using Burp Suite or OWASP ZAP was crucial for understanding nuanced authorization logic.
Identifying BOLA in API Gateway Endpoints
A common pattern we observed was the use of direct object IDs in API requests, such as:
GET /api/v1/orders/12345
In a multi-tenant system, the critical question is: does the API Gateway or the backend Laravel application correctly verify that the authenticated user making this request *belongs* to the tenant that *owns* order 12345? If not, a user from Tenant A could potentially fetch order details from Tenant B by simply changing the ID.
Our initial hypothesis was that authorization checks might be inconsistent, especially for endpoints that bypassed certain API Gateway authorizers or relied solely on the Laravel application for enforcement. We focused on endpoints that:
- Returned sensitive data (e.g., user profiles, financial records, configuration settings).
- Allowed modification of resources (e.g., updating user details, deleting records).
- Accessed resources that were explicitly tied to a tenant context.
Deep Dive: AWS API Gateway Authorization Strategies
AWS API Gateway offers several authorization mechanisms. We examined the configurations for each endpoint:
- IAM Authorization: Used for AWS internal services or when fine-grained AWS IAM policies are sufficient. Less common for typical web APIs.
- Cognito User Pool Authorizers: Integrates with AWS Cognito for user authentication and authorization. JWTs issued by Cognito are validated.
- Lambda Authorizers: A custom Lambda function is invoked to authorize requests. This is a powerful and flexible option, often used for custom JWT validation or complex business logic.
- Resource Policies: Applied at the API, resource, or method level to control access from specific IP addresses or VPCs.
The critical finding was that while some endpoints were protected by robust Lambda Authorizers that injected tenant context into the request headers passed to the backend, others relied on a simpler Cognito User Pool Authorizer. In these cases, the JWT only validated the user’s identity, but not their specific tenant association or their right to access a particular object within their tenant.
Exploitation Scenario: BOLA in Action
Consider an endpoint designed to fetch a user’s profile information:
GET /api/v1/users/{userId}
If the API Gateway endpoint for this path only uses a Cognito Authorizer, it validates that the incoming JWT is valid and belongs to an authenticated user. However, it doesn’t inherently know which tenant that user belongs to, nor does it check if the requested {userId} is within the *same* tenant as the authenticated user. The responsibility then falls entirely on the Laravel application.
An attacker, authenticated as User X from Tenant A, could craft a request like this:
curl -X GET \ 'https://api.example.com/api/v1/users/user-id-from-tenant-b' \ -H 'Authorization: Bearer YOUR_COGNITO_JWT'
If the Laravel application’s controller for this endpoint doesn’t correctly retrieve the authenticated user’s tenant ID from the JWT (or a session/cache derived from it) and then filter the user lookup by that tenant ID, it might inadvertently return User Y’s profile from Tenant B.
Mitigation Strategy: Enhancing API Gateway and Laravel Security
We proposed a multi-layered mitigation strategy:
- Mandate Lambda Authorizers for Sensitive Endpoints: For any endpoint that accesses tenant-specific data, enforce the use of a custom Lambda Authorizer. This authorizer would be responsible for:
- Validating the JWT (e.g., from Cognito or a custom provider).
- Extracting the authenticated user’s tenant ID from the JWT claims.
- Returning an IAM policy that explicitly grants or denies access based on the user’s tenant context and the requested resource.
- Injecting tenant context into the request headers passed to the backend (e.g.,
X-Tenant-ID).
- Strengthen Laravel Application Logic: Ensure that all Eloquent queries and data retrieval operations within the Laravel application are scoped by the authenticated user’s tenant ID. This is a critical defense-in-depth measure, even if API Gateway authorizers are in place.
- Implement API Gateway Request Validation: Utilize API Gateway’s request validation feature to ensure that object identifiers are present and in the correct format. While this doesn’t solve BOLA directly, it can prevent malformed requests from reaching the backend.
- Centralize Tenant Context Management: Develop a robust middleware or service provider in Laravel that reliably extracts and applies the tenant context to all relevant requests.
Implementing Lambda Authorizers
Here’s a simplified example of a Python Lambda Authorizer function that validates a JWT and checks tenant membership:
import json
import jwt
import os
import boto3
# Assume JWT is signed with HS256 and secret is in environment variable
JWT_SECRET = os.environ.get('JWT_SECRET')
REGION = os.environ.get('AWS_REGION')
USER_POOL_ID = os.environ.get('USER_POOL_ID') # For Cognito JWTs
# For Cognito, you might fetch JWKS and verify against them
# For simplicity, we'll assume a shared secret for custom JWTs here.
def get_user_tenant_id(user_id):
# In a real-world scenario, this would query a database
# to find the tenant associated with the user_id.
# For demonstration, we'll use a placeholder.
print(f"Fetching tenant for user_id: {user_id}")
# Example: return 'tenant-abc' if user_id == 'user-123' else None
# This function MUST be robust and secure.
return 'tenant-xyz' # Placeholder
def generate_policy(principal_id, effect, resource, context=None):
auth_response = {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [
{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource
}
]
}
}
if context:
auth_response['context'] = context
return auth_response
def lambda_handler(event, context):
print(f"Received event: {json.dumps(event)}")
# Extract token from Authorization header
auth_header = event.get('authorizationToken', '')
if not auth_header or not auth_header.startswith('Bearer '):
print("No Bearer token found")
return generate_policy('user', 'Deny', event['methodArn'])
token = auth_header.split(' ')[1]
try:
# --- JWT Verification ---
# If using Cognito, you'd typically use the 'PyJWT' library with JWKS
# For custom JWTs with a shared secret:
# decoded_token = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
# Example using Cognito JWT verification (simplified)
# You'd need to fetch the JWKS from your Cognito User Pool's .well-known/jwks.json endpoint
# and use a library like 'jose' or 'PyJWT' with appropriate JWKS fetching.
# For this example, we'll simulate decoding and extracting claims.
# In production, use a robust JWT validation library.
# Placeholder for actual JWT decoding and validation
# Assume token contains 'sub' (user ID) and potentially 'custom:tenant_id' or similar
# For demonstration, let's assume we can extract user_id and tenant_id directly
# In reality, you'd validate signature, expiration, issuer, audience.
# Simulate decoding for demonstration purposes
# Replace with actual JWT validation logic
if token == "valid-token-for-user-123-tenant-abc":
decoded_token = {
'sub': 'user-123',
'custom:tenant_id': 'tenant-abc' # Example custom claim
}
elif token == "valid-token-for-user-456-tenant-def":
decoded_token = {
'sub': 'user-456',
'custom:tenant_id': 'tenant-def'
}
else:
raise jwt.ExpiredSignatureError("Invalid or expired token") # Simulate invalid token
user_id = decoded_token.get('sub')
# If tenant ID is directly in JWT:
# tenant_id_from_jwt = decoded_token.get('custom:tenant_id')
if not user_id:
print("User ID not found in token")
return generate_policy('user', 'Deny', event['methodArn'])
# --- Authorization Logic ---
# Option 1: Tenant ID is in JWT claims (preferred if possible)
# tenant_id_from_jwt = decoded_token.get('custom:tenant_id')
# if not tenant_id_from_jwt:
# print("Tenant ID not found in token claims")
# return generate_policy('user', 'Deny', event['methodArn'])
#
# # Check if the requested resource (e.g., from path parameters) belongs to this tenant
# # This requires parsing event['pathParameters'] or event['queryStringParameters']
# # and comparing against tenant_id_from_jwt. This is highly endpoint-specific.
#
# # For a generic endpoint like /users/{userId}, we need to check if the *requested* userId
# # belongs to the *authenticated* user's tenant.
# requested_user_id = event.get('pathParameters', {}).get('userId')
# if requested_user_id:
# # Fetch the tenant of the requested user
# requested_user_tenant = get_user_tenant_id(requested_user_id) # This is the crucial check
# if requested_user_tenant != tenant_id_from_jwt:
# print(f"User {user_id} (Tenant: {tenant_id_from_jwt}) attempted to access user {requested_user_id} from tenant {requested_user_tenant}")
# return generate_policy('user', 'Deny', event['methodArn'])
# else:
# # Handle cases where userId is not in path parameters, or other resource types
# pass # Implement specific logic
# Option 2: Tenant ID is NOT in JWT, must be looked up (less ideal, adds latency)
# This is where the BOLA risk is higher if not implemented carefully.
# The Lambda Authorizer MUST perform this lookup and enforce it.
authenticated_user_tenant = get_user_tenant_id(user_id)
if not authenticated_user_tenant:
print(f"Could not determine tenant for authenticated user: {user_id}")
return generate_policy('user', 'Deny', event['methodArn'])
# Now, check if the *requested* object belongs to the *authenticated* user's tenant.
# This requires inspecting the event to know *what* is being requested.
# Example: Requesting a specific order ID
# order_id = event.get('pathParameters', {}).get('orderId')
# if order_id:
# # Query DB to find the tenant of this order_id
# order_tenant = get_order_tenant(order_id) # Hypothetical function
# if order_tenant != authenticated_user_tenant:
# print(f"User {user_id} (Tenant: {authenticated_user_tenant}) attempted to access order {order_id} from tenant {order_tenant}")
# return generate_policy('user', 'Deny', event['methodArn'])
# For the /users/{userId} endpoint example:
requested_user_id = event.get('pathParameters', {}).get('userId')
if requested_user_id:
# Crucial BOLA check: Does the requested user belong to the authenticated user's tenant?
# This requires a lookup.
requested_user_tenant = get_user_tenant_id(requested_user_id) # This is the critical lookup
if requested_user_tenant != authenticated_user_tenant:
print(f"User {user_id} (Tenant: {authenticated_user_tenant}) attempted to access user {requested_user_id} from tenant {requested_user_tenant}")
return generate_policy('user', 'Deny', event['methodArn'])
else:
print(f"User {user_id} (Tenant: {authenticated_user_tenant}) authorized to access user {requested_user_id} (Tenant: {requested_user_tenant})")
# Allow access, pass tenant ID to backend
context = {
"tenantId": authenticated_user_tenant,
"userId": user_id
}
return generate_policy(user_id, 'Allow', event['methodArn'], context)
else:
# If no specific user ID is requested (e.g., listing users for the tenant)
# This might be allowed if the user is authenticated and has tenant context.
print(f"User {user_id} (Tenant: {authenticated_user_tenant}) authorized for general access.")
context = {
"tenantId": authenticated_user_tenant,
"userId": user_id
}
return generate_policy(user_id, 'Allow', event['methodArn'], context)
except jwt.ExpiredSignatureError:
print("Token expired")
return generate_policy('user', 'Deny', event['methodArn'])
except jwt.InvalidTokenError:
print("Invalid token")
return generate_policy('user', 'Deny', event['methodArn'])
except Exception as e:
print(f"An error occurred: {e}")
return generate_policy('user', 'Deny', event['methodArn'])
This Lambda function, when configured as a Lambda Authorizer in API Gateway, intercepts requests. It validates the JWT, extracts the user’s identity, and crucially, performs a lookup (or uses claims) to determine the user’s tenant. It then compares this against the tenant context of the requested resource. If they don’t match, access is denied. If they match, it allows the request to proceed and injects the tenant ID into the request context, which can then be accessed by the Laravel application.
Laravel Backend Enforcement
Even with robust API Gateway authorization, the Laravel application must enforce tenant isolation. A common pattern is to use middleware.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\User; // Assuming User model has a tenant_id relationship or attribute
class TenantAware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
// Option 1: Tenant ID injected by Lambda Authorizer into request context
// This is the most secure and recommended approach.
$tenantId = $request->get('tenantId'); // Assuming Lambda Authorizer injects 'tenantId' into request context
// Option 2: Tenant ID extracted from JWT claims if not using context injection
// This requires a JWT parsing middleware before this one.
// $user = Auth::user(); // If user is already authenticated
// $tenantId = $user->tenant_id ?? null; // Or from JWT claims directly
if (!$tenantId) {
// If tenant context is missing, deny access or handle appropriately
// This might happen if the request bypassed the authorizer or if the authorizer failed.
return response()->json(['message' => 'Tenant context is missing.'], 400);
}
// Set the tenant context for the current request.
// This could be stored in a service container binding, a session, or a global scope.
// For Eloquent queries, using global scopes is highly effective.
app()->instance('currentTenantId', $tenantId); // Bind tenant ID to the container
// If using Auth::user(), ensure the user belongs to the correct tenant
if (Auth::check()) {
$user = Auth::user();
// Verify the authenticated user's tenant matches the request's tenant context
// This is a critical defense-in-depth check.
if ($user->tenant_id !== $tenantId) {
// Log this incident - it indicates a potential misconfiguration or attack
\Log::warning("Tenant mismatch: Authenticated user {$user->id} (Tenant: {$user->tenant_id}) accessed resource for tenant {$tenantId}.");
return response()->json(['message' => 'Unauthorized.'], 403);
}
}
// Apply global scope for Eloquent queries to automatically filter by tenant
// This requires a Global Scope to be registered in your User model or a base model.
// Example: User model might have a 'tenant_id' column.
// Your AppServiceProvider would register a scope like:
// User::observe(TenantScope::class); or similar.
return $next($request);
}
}
And registering it in app/Http/Kernel.php:
protected $middlewareGroups = [
'api' => [
// ... other middleware
\App\Http\Middleware\TenantAware::class,
// ... other middleware
],
];
Furthermore, Eloquent Global Scopes are essential for ensuring that all queries automatically respect the tenant context. For instance, if your Order model has a tenant_id column:
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
class TenantScope implements \Illuminate\Database\Eloquent\Scope
{
public function apply(Builder $builder, Model $model)
{
$tenantId = App::make('currentTenantId'); // Retrieve tenant ID from container
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
} else {
// Handle cases where tenant ID is not available - perhaps throw an exception
// or deny access if tenant context is mandatory for this model.
// For models that can be accessed globally (rare in multi-tenant), this logic would differ.
// For strict multi-tenancy, you might want to prevent queries without a tenant.
// throw new \Exception("Tenant context not set for query on model: " . get_class($model));
}
}
}
// In App\Providers\AppServiceProvider.php:
public function boot()
{
// Register the global scope for models that require it
// Example: Order::addGlobalScope(new TenantScope);
// Or use a trait on your models to automatically apply it.
}
This combination of API Gateway Lambda Authorizers and Laravel’s middleware/global scopes provides a robust defense against BOLA vulnerabilities by ensuring that authorization is checked at the edge and enforced at the application layer, with tenant context consistently applied.
Conclusion and Ongoing Monitoring
Auditing and securing high-traffic enterprise applications requires a systematic approach, focusing on critical control points like API gateways. BOLA is a pervasive threat, especially in multi-tenant architectures. By implementing layered security, including custom Lambda Authorizers for granular access control at the API Gateway and strict tenant-aware logic within the Laravel application, we significantly reduced the attack surface. Continuous monitoring of API access logs, authorization failures, and application errors is paramount to detect and respond to any emerging threats.