PRC102 — Another account reached A's object
| Severity | critical |
| OWASP API Security Top 10 | API1:2023 Broken Object Level Authorization |
| Found by | procyon 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,PATCHandPOSTon A's object are sent with the request body's example from the spec. A reads the object (theGETon 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 aGET, the write counts when B's2xxanswer holds A's ID; otherwise Procyon cannot tell, and says so in a note instead of passing it. So does aGETthat does not show the fields the write sets (a password).DELETEon A's object: if A's read afterwards gets404or410, B deleted it. Any other failure (a429, a503) proves nothing and is a note. Without aGETon 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.idsin.procyon.yaml). An operation whose path parameters cannot be filled is listed under Notes. - A write with no
GETon the same path counts only when B's answer holds A's ID; an API that answers204with 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
PUTthen aPATCHwith the same example), a change cannot be seen: a note says so.