DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

RBAC Blocks the Wrong Layer: Mass Assignment Exploits the Fields Authorization Never Checked

RBAC blocks the wrong layer: mass assignment exploits the fields authorization never checked

A low-privilege user sent PATCH /api/v1/users/{id} with {"is_superuser":true} to Langflow. The server returned 200 OK. CVE-2024-7297, CVSS 8.8, fixed in version 1.0.13. The scanner that ran before that request reported no issues.

This is the mass assignment profile: no 403 error, no alert, no anomaly log. The exploit is indistinguishable from a legitimate request to any tool that inspects only the HTTP protocol. RBAC checked the token, checked the resource ID, returned authorized. The is_superuser field was never part of that check.

Partial mitigations exist: ABAC (attribute-based access control) provides field-level grants, and API gateways with schema validation (Spectral, Kong) can reject undocumented fields at the perimeter. The problem is that schema validation does not know a field is privileged — discount_percentage is a valid field in the schema, and a value of 100 is acceptable for an admin. The allowlist in the DTO is the only barrier that operates with the business context needed to distinguish the two cases.

BOLA checks if you can open the door; mass assignment rearranges the room after you are already inside

BOLA (Broken Object Level Authorization) and mass assignment exploit categorically different authorization gaps. BOLA asks: can user A access object B? The middleware checks ownership or role before returning the resource. The attack is blocked at the authorization layer.

Mass assignment asks a different question: can user A set field X on object B that they already have access to? That question is never asked. Authorization passed at the object level. Request body parsing happens afterward, with no property-level check.

The attacker exploiting BOLA changes the object ID in the URL. The attacker exploiting mass assignment changes the field list in the request body. Same endpoint, same authentication token, same response code: 200 OK.

OWASP consolidated Excessive Data Exposure and Mass Assignment under API3:2023 BOPLA for precisely this structural reason. The root cause is missing authorization at the property level, not the object level. RBAC never operated at that layer.

ORMs defaulted to auto-binding because velocity was the priority

In March 2012, Egor Homakov added public_key[user_id] to a form on GitHub. The Rails app called @key.update_attributes(params[:public_key]) with no allowlist. Homakov's SSH key was associated with the Rails organization account, and he committed directly to the rails/rails repository.

No authentication was broken. No injection was exploited. The server accepted a field it should not have accepted because no code checked which fields were permitted.

The Rails community response was to introduce strong_parameters as a breaking change in Rails 4 (2013). Auto-binding was removed from the default, and an explicit allowlist became required. The issue was resolved in the framework, but the lesson was not inherited by applications built on other stacks.

CVE-2024-7297 was published in 2024, twelve years after the GitHub incident. HackerOne #99424 documented the same pattern on partners.uber.com: a financial field accepted by the API without authorization validation returned 200 OK for any authenticated user. The structure is identical to 2012: the ORM accepts unauthorized fields because the application never restricted them.

The attack surface scales with feature velocity. Every ticket that adds a field to the model extends writable parameters without notification, unless the developer knows they need to update an explicit allowlist.

Rails requires explicit permit; NestJS PartialType silently inherits all fields; FastAPI needs two schemas

Each framework has a distinct exposure pattern. Rails learned from 2012 and requires an explicit list before any assignment:

# Rails: allowlist obrigatório
params.require(:user).permit(:name, :email)
# Qualquer campo fora do permit() e descartado antes de chegar ao model
Enter fullscreen mode Exit fullscreen mode

Any field absent from permit() is discarded before reaching the model. Security by default, enforced by the framework.

NestJS presents the most insidious pattern. PartialType is documented as the idiomatic way to create update DTOs:

// NestJS -- vulneravel
import { PartialType } from '@nestjs/mapped-types';

export class CreateUserDto {
  name: string;
  email: string;
  role: string;       // aceito via PATCH
  isAdmin: boolean;   // aceito via PATCH
  credits: number;    // aceito via PATCH
}

export class UpdateUserDto extends PartialType(CreateUserDto) {}
// Todos os campos do pai ficam opcionais e aceitaveis
Enter fullscreen mode Exit fullscreen mode

All fields from the parent DTO, including role, isAdmin, and credits, become optional and accepted at the update endpoint. The fix requires a separate DTO with explicit fields:

// NestJS -- corrigido
import { PickType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PickType(CreateUserDto, ['name', 'email'] as const) {}
// Somente name e email sao aceitos, independente do que for adicionado ao CreateUserDto
Enter fullscreen mode Exit fullscreen mode

FastAPI with Pydantic exposes the problem when a single schema serves both input and output:

# FastAPI -- vulneravel: mesmo schema para input e output
class User(BaseModel):
    name: str
    email: str
    role: str = user
    is_verified: bool = False
    credits: int = 0

@router.patch(/users/{id})
async def update_user(id: str, data: User):
    # role, is_verified e credits sao aceitaveis como input
    ...
Enter fullscreen mode Exit fullscreen mode

The fix uses two separate schemas with no shared inheritance:

# FastAPI -- corrigido: schemas distintos para input e output
class UserUpdate(BaseModel):
    name: str
    email: str
    # role, is_verified, credits ausentes: nao aceitaveis como input

class UserResponse(BaseModel):
    name: str
    email: str
    role: str
    is_verified: bool
    credits: int

@router.patch(/users/{id})
async def update_user(id: str, data: UserUpdate) -> UserResponse:
    ...
Enter fullscreen mode Exit fullscreen mode

Response fields cannot become input fields by accident when the schemas share no inheritance.

Automated scanners certify mass assignment as clean

DAST tools detect anomalies in response behavior: 403, 500, error messages, injection outputs. Mass assignment produces none of those signals. The server accepts, stores, and returns the injected field identically to a legitimate request.

The OWASP WSTG confirms: automated tools frequently miss the attack because no error indicator is present. The exploit is indistinguishable from a normal write from the protocol perspective.

The PortSwigger Web Security Academy lab demonstrates this with the chosen_discount field: present in the GET /checkout response body, absent from the POST /checkout documentation. No DAST scanner implements the GET-versus-POST comparison by default to detect extra accepted fields.

CVE-2024-7297 (Langflow): the endpoint behaved correctly from the scanner's perspective in every test before CVE disclosure. The is_superuser field was present in the GET /api/v1/users/{id} response. No automated tool connected presence in the output with acceptance in the input.

Auditing mass assignment requires an inventory of every accepted parameter

A mass assignment audit cannot begin without a complete map of every endpoint, every accepted field, and every model property exposed by the API. You cannot build an allowlist for fields you do not know exist.

The manual procedure follows a replicable pattern: send the full body of a GET response as the body of a PATCH. Check whether privileged fields (role, credits, isAdmin) affect the response of a subsequent GET. The difference between what the endpoint accepts and what the documentation describes is the attack surface.

Critical fields to enumerate first: role, isAdmin, is_verified, is_superuser, credits, balance, price, discount, status, plan, permissions, organization_id. Each maps to a privilege or financial control.

CVE-2024-7297 would have been found by any tool that compared GET attributes against PATCH-accepted fields. The is_superuser field was present in both. Without that field-by-field inventory, the vulnerability does not exist for the scanner.

Denylist fails because new fields are added without updating the list

Blocking sensitive fields individually is a losing strategy. The developer adds the credits field to the model for a billing feature. They do not update params.except(:admin_only). The credits field is immediately writable through the public profile update endpoint. The security team is not notified.

With an allowlist, the same scenario produces the opposite result. The developer adds credits to the model and does not add it to params.permit(:name, :email). The field is discarded on every write with no additional action required.

The rule by framework:

// Express/Node: allowlist manual antes de qualquer atribuicao
const allowed = ['name', 'email'];
const safeUpdate = Object.fromEntries(
  Object.entries(req.body).filter(([k]) => allowed.includes(k))
);
await User.update(safeUpdate, { where: { id } });
Enter fullscreen mode Exit fullscreen mode

NestJS: replace PartialType(CreateUserDto) with PickType(CreateUserDto, ['name', 'email'] as const) on every update endpoint. Django REST Framework: replace fields = '__all__' with an explicit list and declare read_only_fields = ['role', 'is_verified', 'credits'] for every sensitive field. FastAPI: UserUpdate and UserResponse as schemas with no shared inheritance.

The inventory of accepted parameters and the inventory of documented parameters rarely match. That gap is the attack.

Top comments (0)