How a Missing Owner Check Exposed Every Order
On this page
A shopping API let any signed-in user read any order just by changing a number in the URL. The server checked that you were logged in, but never that the order was yours. This is a demo write-up that shows the house style for a finding.
What the product promises
The account page shows only your orders, and the UI never links to anyone else's. That implies a simple rule: an order belongs to one account, and only that account can read it.
Platform action vs. user action
The user never chose to share an order. The platform exposed it on its own, because the endpoint that returns an order checks authentication (are you a valid user?) but not authorization (is this order yours?). There is nothing the user can do to fix it from their side.
Reproduction
Sign in as account B and request an order that belongs to account A:
GET /api/orders/1001 HTTP/1.1
Host: shop.example
Authorization: Bearer <account-B-token>
The server returns account A's order in full:
{
"id": 1001,
"owner": "account-A",
"total": "149.00",
"address": "12 Example Street, Baghdad",
"items": [{ "sku": "KB-88", "qty": 1 }]
}
Incrementing the id walks through every order in the system, one request at a time.
Impact
Any authenticated user can read any order: names, addresses, items and totals. Order IDs are sequential, so the whole table can be enumerated with a simple loop. This is a full, horizontal access-control break on customer data.
Confirmed vs. requires verification
| Claim | State | Evidence |
|---|---|---|
| Account B reads account A's order | Confirmed | Response above, taken with B's token |
| IDs are sequential and enumerable | Confirmed | 1001, 1002, 1003 all resolve |
| Payment details are exposed | Requires verification | Not present in this endpoint's response |
Root cause
The handler loads the record by ID and returns it, with no check that the record's owner matches the caller:
@app.get("/api/orders/{order_id}")
def get_order(order_id: int, user = Depends(current_user)):
order = db.orders.get(order_id) # found by id alone
return order # owner is never compared to user
Authentication answered "who are you"; nobody asked "is this yours".
Remediation
Compare the record's owner to the caller, and return 404 (not 403) so the endpoint does not confirm that an ID exists:
@app.get("/api/orders/{order_id}")
def get_order(order_id: int, user = Depends(current_user)):
order = db.orders.get(order_id)
if order is None or order.owner != user.id:
raise HTTPException(status_code=404)
return order
As defense in depth, prefer non-sequential IDs (UUIDs) so records cannot be guessed and enumerated.
Timeline
| Date | Event |
|---|---|
| 2026-10-15 | Demo published |