PROCYON

PRC102 — Another account reached A's object

Severitycritical
OWASP API Security Top 10API1:2023 Broken Object Level Authorization
Found byprocyon test (runtime)

What it catches

Broken object level authorization (BOLA): one user reaching another user's object by putting its ID in the path.

procyon test first calls the API as test account A and collects the IDs its responses show (GET /notes shows note 1), then calls the same operations as test account B with A's IDs (GET /notes/1). For a read, it is a finding when B gets a 2xx holding values A's own answer for the same object held, and that B's own reads (those with nothing in the path: lists, /me) never showed.

A 2xx alone is not enough: an API may answer B with B's own data or an empty list, which is fine. A value every user can see elsewhere (a public list of usernames) is never counted as A's, and neither are the path's own values (an API may echo the ID it was asked for), the words true and false, short numbers or short words.

Objects of their own. Before reading, each account adds one object to every collection the spec lets it add to: a POST on a path that also has a GET, or a GET on one of its items (POST /notes next to GET /notes or GET /notes/{id}), with the spec's example. So an environment that seeds no data still gives A an object for B to try, and a note lists what was created. A POST /logout or /reset-password has no such GET, and is never sent. A created object is only used when the environment seeded none: a seeded one holds real data. B's write uses the example with one text field changed, so it differs from what A's object holds; if the server refuses that (the field was an email or an enum), B sends the example as it is.

Which object is A's. When a list shows every user's objects (VAmPI's GET /books/v1), Procyon picks the one that holds a value marking A: what A's own reads of a path show and B's reads of the same path do not (A's username in GET /me). A's book behind a public title is then tested, and its secret, read by B, is a finding. When nothing marks any listed object as A's, B is not tested on it: a note says to declare one of A's IDs under test.ids in .procyon.yaml.

The finding's evidence is B's request and the start of the response. Tokens are masked.

Writes and deletes are checked too, after every read (deletes last), so a destructive request never removes what a later one needs:

  • PUT, PATCH and POST on A's object are sent with the request body's example from the spec. A reads the object (the GET on the same path) before and after: if afterwards it holds the example's values and before it did not, B changed it. Only those fields are compared, so a timestamp or a view counter in the response never counts as a change. Without such a GET, the write counts when B's 2xx answer holds A's ID; otherwise Procyon cannot tell, and says so in a note instead of passing it. So does a GET that does not show the fields the write sets (a password).
  • DELETE on A's object: if A's read afterwards gets 404 or 410, B deleted it. Any other failure (a 429, a 503) proves nothing and is a note. Without a GET on the same path, Procyon cannot tell, and says so in a note.
  • Deletes go deepest path first, so deleting /users/{id} never takes /users/{id}/notes/{noteId} with it before that one is tested.

An operation that needs a request body the spec gives no example for is listed under Notes.

Why it matters

It is the most common API vulnerability (OWASP API1). A spec cannot show it: the operation is authenticated, and the missing check ("is this object yours?") is a line of server code. Anyone with an account can walk the IDs (/notes/1, /notes/2, …) and read everything.

How to fix it

On the server, load the object and check that it belongs to the caller (or that the caller may see it) before returning it. Answer 404, not 403, so the response does not confirm that the ID exists. Prefer doing the check in one place (a query scoped to the caller: WHERE id = ? AND owner_id = ?) over a check in each handler.

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.

/articles/{id}:
  get:
    x-procyon-ignore:
      - rule: PRC102
        reason: "Articles are readable by every signed-in user"

Examples

Reported: A's GET /notes shows note 1, and

GET /notes/1          (as B)
200 {"id":"1","owner":"alice","title":"Groceries",...}

Not reported: the same request answered 404, or 200 with B's own note ({"id":"3","owner":"bob"}), or 200 [].

Known limits

  • IDs come from A's responses (or test.ids in .procyon.yaml). An operation whose path parameters cannot be filled is listed under Notes.
  • A write with no GET on the same path counts only when B's answer holds A's ID; an API that answers 204 with no body is a note instead.
  • An object meant to be readable by every user, with data only it holds, looks like VAmPI's book secret: B reading it is reported. Ignore it on that operation with the reason.
  • Values B's own object on the same path also holds are not counted, when Procyon can tell which object is B's (B's own GET /me); otherwise a generic word in both objects can still count.
  • A's discovery reads nested paths (/projects/{id}/tasks) under the first parent it finds, before it knows which one is A's.
  • When A's object already holds the example's values (for instance after an anonymous write, or a PUT then a PATCH with the same example), a change cannot be seen: a note says so.