PROCYON

PRC009 — Client can write a privileged field

Severitymedium
OWASP API Security Top 10API3:2023 Broken Object Property Level Authorization (mass assignment)

What it catches

A request body field that grants privileges and that the client is allowed to send: not readOnly: true (on the field, or on one of its allOf parts, which is how OpenAPI 3.0 makes a $ref read-only), and not inside a read-only object. Request bodies are checked through nested objects, allOf/oneOf/anyOf and $refs.

A field grants privileges when its name's head word is one of role, admin, administrator, superuser / super user, permission, privilege, scope, tenant (or the Portuguese papel, administrador, permissão, privilégio, with or without accents), or its plural, optionally followed by an identifier word (id, uuid, name, code, key, slug, type, level, list, and plurals).

Not checked, on purpose:

  • Items of arrays. A list of child records, such as chat messages with a role, is not the record being assigned. A privileged array field itself (permissions: [...]) is still reported.
  • OAuth2 token requests (a body with grant_type): asking for a scope is what they are for.
  • A role or permission assigned on purpose, for the words role, permission, scope (and papel, permissão), never admin, superuser, tenant:
    • on an operation that addresses one grant: a segment member(ship), collaborator, invitation, role, permission (or membro, papel, permissão) followed by a parameter, such as PUT /orgs/{org}/memberships/{username} (a role or permission; a scope only through its enum, below). Assigning the role is what it is for; who may call it is an authorization question (PRC010, and runtime tests). A collection (POST /members) or another resource (/permissions-check) is not one, and other privileged fields in the same body (tenantId, isAdmin) are still reported;
    • when its enum holds only strings made of low values (viewer, view, reader, read, readonly, guest, commenter, member, user, basic, none, no, access, public, anonymous, visitor, and Portuguese): guestRole: viewer | commenter. Any other value (admin, superadmin, delete:users), or a boolean, number or null, is a privilege a client could pick.
ReportedNot reported
role, userRole, roleIds, roleIDs, isAdmin, isSuperUseradminNotes (the head word is notes)
permissions, permissões, scopes, tenantId, tenantUuidthe same fields with readOnly: true
the same fields in responses only, or in messages[].role
default_role: reader | writer | admin, isAdmin: [true, false], tenantId on /memberships/{username}guestRole: viewer | commenter, role on /memberships/{username}

Why it matters

Mass assignment: many frameworks copy the request body straight into the database record. If the schema lets the client send isAdmin or role, any user can try {"isAdmin": true} on their own profile and promote themselves, or move their record to another tenant.

How to fix it

Mark the field readOnly: true, or remove it from the request schema, and make the server ignore it on ordinary updates. Change privileges only through a dedicated operation that checks who is allowed to.

role:
  type: string
  readOnly: true

How to ignore it

If the finding is fine on purpose, write an x-procyon-ignore on the operation, with the reason. The finding is still reported, under Ignored, and no longer fails the run. See ignoring a finding.

/admin/users:
  post:
    x-procyon-ignore:
      - rule: PRC009
        reason: "Only admins can call this operation; enforced by the gateway"

Examples

Reported:

/users/me:
  patch:
    requestBody:
      content:
        application/json:
          schema:
            properties:
              name:
                type: string
                maxLength: 100
              isAdmin:
                type: boolean

Not reported: isAdmin with readOnly: true, or left out of the request schema.

Known limits

Procyon reads the spec. If the server accepts a privileged field the spec does not document (a hidden admin field), only a runtime test can find it; that is planned for Procyon v0.2.