DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

BOPLA: Why APIs Return Fields They Should Not and How to Detect It

BOPLA: Why APIs Return Fields They Should Not and How to Detect It

CVE-2023-34235, disclosed in February 2023, exposed Strapi before version 4.10.8: unauthenticated callers could retrieve administrator password hashes and reset tokens with a single API request. The bypass was minimal. The password field appeared in the exclusion list. The t1.password field did not. The exclusion list checked the literal string name. It did not check the field resolved by the ORM.

The read-write duality is the core of BOPLA. The same endpoint returning passwordHash in a GET response frequently accepts is_admin: true in a PATCH body, for the same reason: no authorization check at the field level. OWASP merged API3:2019 (Excessive Data Exposure) and API6:2019 (Mass Assignment) into API3:2023 because "return less data" was the wrong diagnosis. The failure is not filtering. It is authorization. ORM frameworks serialize complete models by default. Applications protect fields through exclusion lists. And the attacker who found the field in a GET already knows exactly what name to use in the PATCH.

The Two Attack Surfaces: Reading Exposes, Writing Exploits

The read side starts at the framework level. Django REST ModelSerializer, Rails as_json, SQLAlchemy to_dict return every model column by default. The application adds an exclusion list. The ORM model remains complete in memory. Any alternative path to access the field bypasses the string-based exclusion check.

CVE-2023-34235 (Strapi < 4.10.8, CVSS 8.6) demonstrated this without ambiguity: the t1.password filter prefix bypassed the password exclusion. CVE-2023-22894 (Strapi < 4.8.0, CVSS 7.5) arrived through the same route with query filter manipulation on the admin API: the ORM processed filters referencing fields the serializer was supposed to hide. CVE-2023-46128 (Nautobot 2.0.0-2.0.2, CVSS 6.5) used the ?depth=N parameter to traverse the ORM relationship graph layer by layer, without field-level authorization checks at any depth, exposing password hashes to any authenticated user.

The write side inverts the direction and raises the impact. CVE-2024-56143 (Strapi 5.0.0-5.5.1, CVSS 8.2) showed the lookup operator accepting password and resetPasswordToken in mutation bodies without sanitization. No authentication required. The insufficient sanitization logic in version 5.x applied to both read and write operations because the same domain model fed both directions. CVE-2022-46792 (Hasura GraphQL Engine 2.10.0-2.15.1, CVSS 8.8) exposed the same pattern in GraphQL: the Update Many API for Postgres backends accepted field changes that row-level authorization policies should have blocked. Object-level authorization was present. Field-level authorization was absent.

The exploitation pattern is direct. An attacker inspects the GET /api/users/:id response, catalogs every field including is_admin, role, account_tier, subscription_plan, and replays suspicious fields in a PATCH /api/users/:id body. The server accepts because it never verified whether this caller has authorization to write that specific field. No field names needed to be guessed. The read side supplied them.

HackerOne Evidence: Three Reports, the Same Causal Chain

H1 #1489892 (UPchieve, 2022) required no special payload or sophisticated technique. GET /api/users?page=1&firstName=test returned passwordHash for every user in the paginated response. The vulnerability was detectable through standard HTTP traffic inspection of the admin search UI. The ORM serialized the field because it existed in the model. The exclusion list did not cover the paginated user search endpoint.

H1 #267781 connects read exposure to write exploitation explicitly. A field present in GET /api/me was replayed in a PATCH /api/me body and enabled access to a paid API tier without payment. The field was not writable by regular users according to business rules. The API did not check the distinction between writable and non-writable fields for that account level.

H1 #99424 (Uber partners.uber.com, 2015) showed the same pattern on a production partner-facing API. Driver account fields that should have been immutable after background check approval remained writable through the profile update endpoint. Object-level authorization verified that this driver could update their own profile. Field-level authorization did not verify whether fields locked by process stage could be modified.

Detection: Differential Response Analysis

Differential analysis does not require an exploit. It requires systematic comparison of field sets across different privilege contexts. Send GET /api/me as an unauthenticated user, as a basic plan user, and as an administrator. Catalog every field at each level. Any field present at a lower privilege level that is absent at a higher privilege level is a BOPLA candidate on the read side.

For the write side, compare the read surface against the write surface of the same endpoint. List every field that GET /api/users/:id returns. Replay each suspicious field in a PATCH /api/users/:id body with a different value than the current one. A server that applies is_admin: true or subscription_plan: enterprise sent by an unprivileged user has confirmed BOPLA on the write side.

The MAGO team tool (mago.team) automates field surface comparison between read and write endpoints. It sends authenticated requests at multiple privilege levels, collects the field sets from each response, and flags fields present in lower-privilege contexts that are absent from higher-privilege baselines. Manual comparison scales to tens of endpoints. Automation is necessary when the API surface has hundreds of endpoints or when fields diverge based on query parameters like ?depth=N or ORM filters.

Defense: Allowlist, Distinct DTOs, Default Deny

Exclusion-based serialization is not a defense against BOPLA. The fix requires allowlist-based serialization with explicit separate DTOs for reading and writing. The read DTO lists exactly which fields this caller can see in this context. The write DTO lists exactly which fields this caller can modify. Any field outside the read DTO is omitted from the response. Any field outside the write DTO is ignored in the request.

Field-level authorization checks belong at the resolver level, not the object level. Object-level authorization verifies whether the caller can access the object. Field-level authorization verifies whether the caller can access this specific field of this object in this context. The two are independent and complementary. An authenticated user with access to their own profile may not have authorization to read reset_token or write is_admin. CVE-2022-46792 confirmed that row-level policies in Hasura do not substitute field-level checks in mutations: each sensitive field resolver needs its own independent authorization decision.

For GraphQL specifically: introspection disabled in production removes automatic schema enumeration. Authorization verified at each field resolver that returns sensitive data, with a default-deny policy, eliminates the entire class of attacks from unchecked nested resolvers.

Three CVEs, 18 Months, the Same Bug

CVE-2023-34235 in Strapi 4.x. CVE-2024-56143 in Strapi 5.x. CVE-2022-46792 in Hasura. Published over 18 months. Different frameworks, different versions, the same root cause: the domain model exposed directly to serialization without field-level authorization checks.

Framework documentation contributes to the pattern. read_only_fields, exclude = ['password'], @HideField() are presented as security mechanisms. They are not. They are serialization conventions that any caller with access to the ORM model or the GraphQL schema can bypass with a parameter variation. The only defense that breaks the attack class is an explicit allowlist with default deny, checked field by field, on reads and writes, at the resolver level where the caller's context is available for the decision.

Top comments (0)