Broken Object Level Authorization (BOLA / IDOR)

ID

broken_object_level_authorization

Severity

high

Remediation Complexity

medium

Remediation Risk

medium

Remediation Effort

medium

Family

API1:2023 - Broken Object Level Authorization

CWE

CWE-639, CWE-284

Resource

authorization

Language

any (model-level) + Java / C# (AST Phase C) + Python / JS / Go / PHP (regex Phase B)

Description

Reports API endpoints that access objects by an ID-shaped path or query parameter (/orders/{id}, /users/:id, /documents?id=…) and require authentication, but show no model-level evidence that the handler verifies the caller’s ownership of the referenced object.

This is the classic Insecure Direct Object Reference (IDOR) pattern and the OWASP API1:2023 — Broken Object Level Authorization — entry. Per-language source-walking (Phase B) can lift the verdict to HIGH when no explicit ownership check is found in the handler body; AST-based confirmation (Phase C) extends the check across one-hop helper calls and ORM-side row scoping in Java and C#.

Rationale

Authentication confirms who the caller is. Object-level authorization is the orthogonal question — which objects is this caller allowed to see or change?

An endpoint that returns the order whose id is in the URL — without verifying the order belongs to the caller — lets any authenticated user enumerate every order in the system simply by incrementing the id. BOLA is the most-reported finding in public API breach catalogs (OWASP API1 since 2019 and unchanged at the top in 2023). Real-world consequences include cross-tenant data leaks in B2B SaaS, mass enumeration of medical records, and credential / token theft via account-recovery flows that fetch by user id.

Authorization is not "did I write a check?" but "does the check actually scope by user identity?" — a frequent failure mode is a guard that confirms the caller is logged in (already enforced by auth) without comparing the resource owner to the caller, leaving BOLA wide open.

Remediation

The fix is to bind every object-by-id access to the caller’s identity at the data layer or via an explicit guard. In order of robustness:

  • Repository-scoped queries — the strongest. Replace findById(id) with findByIdAndOwner(id, currentUser) (Spring Data), Model.objects.filter(user=request.user).get(pk=id) (Django), findOne({_id: id, userId: req.user.id}) (Mongoose), Where("user_id = ?", userID).First(&order, id) (GORM), or Order::where('user_id', auth()→id())→findOrFail($id) (Eloquent). The caller cannot escape the filter.

  • Policy / authorization framework@PreAuthorize("@bookService.canRead(#id, principal)"), ASP.NET IAuthorizationService.AuthorizeAsync(user, resource, policy), Laravel $this→authorize('view', $order), DRF check_object_permissions(request, obj).

  • Explicit guard — load the object, compare obj.userId == currentUser.id, return 403 / 404 on mismatch. Acceptable but easy to skip on a refactor; prefer the patterns above.

Avoid relying on "the user only sees their own ids in the UI" — assume any authenticated caller will iterate the id space.

Configuration

The detector recognises a configurable set of id-shaped parameter patterns (id, *Id, *_id, uuid) — patterns can be extended via the idParameterPatterns property. The model-level pass is the cheap floor; per-language Phase B / Phase C source walking is automatic when the handler file is parseable.

Two further properties tune what counts as an ownership check:

  • mediatorMethods — per-language names of your own authorization helpers (assertCanAccess, check_object_permissions). A call to one inside the handler counts as an ownership check. Do not list role checks or authentication-only helpers here: they answer a different question and would silence the finding on every guarded endpoint.

  • privilegedRoles — role names whose holders are meant to reach objects that are not their own, so that an endpoint gated on one of them is not reported. The shipped list covers the administrator / root family in English (admin, administrator, superadmin, superuser, root, sysadmin) and replaces, rather than extends, the default when you set it — an application whose admin role is gestor or beheerder lists it here to avoid a false positive on every admin endpoint. Names are compared whole and case-insensitively with Spring’s ROLE_ / SCOPE_ prefix stripped, so TENANT_ADMIN is not treated as privileged (a tenant admin reaching another tenant’s object by id is the canonical multi-tenant BOLA), and a list of alternatives counts only when every name in it is privileged (hasAnyRole('ADMIN','USER') lets an ordinary user in). Scope-ambiguous roles — manager, support, staff, moderator, operator — are deliberately not shipped as privileged, because "a support agent can read any customer record by id" is a finding most teams want to see; add them if your application intends otherwise.

Unless your project uses unusual id-parameter naming, non-English role names, or its own authorization helpers, you typically do not need to configure this detector.

References