ab_alawsi
  1. Home
  2. Writeups
  3. Write-up
Text
Write-up

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.

Two accounts request the same order by ID; only one owns it, but the server never checks.
The server authenticates the caller but never verifies ownership.

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

ClaimStateEvidence
Account B reads account A's orderConfirmedResponse above, taken with B's token
IDs are sequential and enumerableConfirmed1001, 1002, 1003 all resolve
Payment details are exposedRequires verificationNot 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

DateEvent
2026-10-15Demo published