Bola Idor in Rails with Api Keys
Bola Idor in Rails with Api Keys — how this specific combination creates or exposes the vulnerability
Broken Object Level Authorization (BOLA), also referred to as IDOR when exploited horizontally across user boundaries, occurs when an API exposes direct object references (e.g., /users/123/account) without verifying that the requesting actor is authorized for that specific resource. In Rails, this commonly manifests when controller actions load records via params (e.g., current_user.accounts.find(params[:id])) but the authorization check is incomplete or inconsistent. Introducing API keys into this mix can inadvertently widen the attack surface if keys are treated as a substitute for proper user-level authorization.
Consider a Rails API that uses API keys to identify an integration or a service account, and then performs record lookups scoped to that key’s owner. A route like GET /api/v1/organizations/:org_id/members might accept an API key in an Authorization header, find the organization by org_id, and then return members. If the code does not verify that the API key’s associated organization is the same as org_id, an attacker can iterate over org_id values and access data belonging to other organizations. The root cause is treating the API key as a global gate while omitting a per-request ownership assertion at the model level. This is BOLA because the object-level policy (membership in an organization) is not enforced for each resource access.
Rails-specific patterns that encourage this mistake include reliance on default_scope or association proxies without double-checking the foreign key. For example, if ApiKey.find_by(value: request.headers["Authorization"]&.split(" ")&.last) sets an @current_organization via a before_action, and the controller then does @organization.members.find(params[:id]), the authorization on @organization might be bypassed if the lookup does not re-assert that the found member actually belongs to @organization. An attacker supplying a valid API key for Org A but iterating :org_id to Org B can observe whether records exist and infer data or behavior, especially when error messages differ between found and not-found states.
Another common scenario involves nested resources where shallow routes and polymorphic associations obscure the boundary. Imagine an endpoint GET /api/v1/projects/:project_id/tasks/:id that authenticates via an API key tied to a contractor. If the code loads @project = current_contractor.projects.find(params[:project_id]) but then loads @task = Task.find(params[:id]) without re-scope to @project, the contractor might read tasks from other projects by guessing IDs. The API key here identifies the contractor, but the authorization on Task must explicitly verify project membership; otherwise BOLA exists despite the presence of the key.
Serialization layers (such as ActiveModel::Serializer or Jbuilder) can also contribute to the risk by exposing associations that should be constrained. Even when the controller correctly scopes the initial query, overly broad as_json or serializable_hash calls might include nested records that were not authorized. For example, rendering @project.to_json(include: :tasks) without ensuring tasks are preloaded through the scoped query can leak data across organizational boundaries when the API key does not enforce row-level checks at serialization time.
In practice, scanning an API with middleBrick that uses API keys will surface BOLA findings when endpoints expose direct object references without validating the caller’s relationship to the targeted resource. The scanner does not know your key semantics; it observes whether record-level authorization is consistently applied across requests with different identifiers. Remediation requires aligning the API key’s intended scope with explicit model-level checks on every resource access, ensuring that ownership or membership is verified in the query rather than assumed by earlier lookup logic.
Api Keys-Specific Remediation in Rails — concrete code fixes
To fix BOLA when API keys are used, enforce record ownership or membership in the data-access layer rather than relying on key-scoped variables alone. Always load records through associations that include the key’s scope, and avoid trusting client-supplied identifiers without re-verifying them against the scoped relation.
Example: API key identifying an organization
class Api::V1::MembersController < ApplicationController
before_action :authenticate_api_key
def index
# Correct: re-scope the query using the authenticated key’s association
members = @current_organization.members.where(id: params[:id])
render json: members
end
def show
# Correct: find through the scoped association to enforce BOLA
member = @current_organization.members.find(params[:id])
render json: member
end
private
def authenticate_api_key
key = request.headers["Authorization"]&.split(" ")&.last
@current_organization = ApiKey.organization_key.find_by(value: key)&.organization
head 401 unless @current_organization
end
end
Example: API key identifying a contractor with nested resources
class Api::V1::TasksController < ApplicationController
before_action :authenticate_api_key
def show
# Correct: ensure the task belongs to the contractor’s project
@task = @current_contractor.tasks.find(params[:id])
render json: @task
end
def update
# Correct: scope by both project and contractor to prevent cross-project access
@task = @current_contractor.tasks.find(params[:id])
if @task.update(task_params)
render json: @task
else
render json: @task.errors, status: :unprocessable_entity
end
end
private
def authenticate_api_key
key = request.headers["Authorization"]&.split(" ")&.last
# Assume ApiKey belongs_to :contractor and Contractor has_many :projects
@current_contractor = ApiKey.contractor_key.find_by(value: key)&.contractor
head 401 unless @current_contractor
end
def task_params
params.require(:task).permit(:title, :description, :status)
end
end
General guidelines
- Always load resources through a scoped association that includes the entity represented by the API key (organization, contractor, service account).
- Avoid using find(params[:id]) on a global model; prefer Model.where(association: current_entity).find(params[:id]).
- Do not rely on default_scope for row-level security; use explicit joins or where clauses tied to the key’s owner.
- Ensure JSON serialization does not bypass scoping by preloading authorized associations rather than passing open ActiveRecord relations to as_json or to_json.
Related CWEs: bolaAuthorization
| CWE ID | Name | Severity |
|---|---|---|
| CWE-250 | Execution with Unnecessary Privileges | HIGH |
| CWE-639 | Insecure Direct Object Reference | CRITICAL |
| CWE-732 | Incorrect Permission Assignment | HIGH |