Skip to content
Simone Russo
Back to security notes

Security / training lab

Log in as Alice. Read Bob's invoice.

A tiny invoicing API that runs on your machine. Login works. Sessions work. The lookup works. The one missing piece is the check that decides whether this invoice is yours. Play it below, then reproduce it for real in two terminals.

  • 127.0.0.1 only
  • Synthetic data
  • CWE-639
  • OWASP API1:2023
01

Interactive explainer

The check that isn't there

The same logic as the lab server, drawn as a request. Choose a session and an invoice, then send it. Flip the build to Fixed and send the exact same request.

Session
Invoice
Build

Once a request has run, changing any option replays it with the new configuration.

Server trace

idle

  1. 01Session
  2. 02Handler
  3. 03Lookup
  4. 04Ownerskipped
RequestGET /api/invoices/2

$ GET /api/invoices/2

Alice reading invoice 2 is already set up. Press “Send request” to walk the trace.

Response

—

application/json
…

Alice reading invoice 2 is already set up. Press “Send request” to walk the trace.

Client-side simulation: it mirrors the logic in labs/idor/app.mjs and does not run the server.

02

Hands-on

Two terminals, six steps

One terminal keeps the server alive; the other sends requests. Every command is copy-paste, and the data is fake. The whole loop takes about two minutes.

  1. 01

    Get the code

    Clone the repository and enter it. There is no install step: the lab is plain Node with no dependencies.

    Terminal
    git clone https://github.com/ErRusso/errusso.github.io.git
    cd errusso.github.io
  2. 02

    Start the vulnerable build

    Leave this running in the first terminal. The server prints the loopback URL and never binds to a public interface.

    Terminal
    node labs/idor/vulnerable.mjs

    Output

    VULNERABLE training API: http://127.0.0.1:8787
    Synthetic data only. Loopback only. Do not deploy.
  3. 03

    Log in as Alice

    The session cookie is written to /tmp/idor.cookie. Alice is user 1; her invoices are 1 and 3.

    Terminal
    curl -s -c /tmp/idor.cookie \
      -d 'username=alice&password=alice' \
      http://127.0.0.1:8787/login

    Output

    {"user":{"id":1,"username":"alice"}}
  4. 04

    Change the id

    You are still Alice. Invoice 2 belongs to Bob (user 2). The vulnerable build answers with 200 and the full invoice.

    Terminal
    curl -s -b /tmp/idor.cookie \
      http://127.0.0.1:8787/api/invoices/2

    Output

    {"id":2,"ownerId":2,"customer":"Bob Srl","amount":5400,"currency":"EUR"}
  5. 05

    Swap in the fixed build

    Stop the server with Ctrl-C, start the fixed build, and repeat the same request with the same cookie. It returns 404: the object is hidden, not confirmed.

    Terminal
    node labs/idor/fixed.mjs
    curl -s -o /dev/null -w '%{http_code}\n' \
      -b /tmp/idor.cookie \
      http://127.0.0.1:8787/api/invoices/2

    Output

    404
  6. 06

    Run the regression test

    One command starts both builds and asserts the leak, the denial, owner access and anonymous 401 — in both directions.

    Terminal
    node labs/idor/test.mjs

    Output

    [vulnerable] alice -> invoice #2: 200 (Bob Srl)
    [fixed] alice -> invoice #2: 404
    [fixed] alice -> invoice #1: 200
    [fixed] anonymous -> invoice #1: 401
    [fixed] bob -> invoice #2: 200
    PASS: 9 assertions

If curl returns nothing, the server is probably not running in the other terminal. The port is fixed at 8787; override it with PORT=8788 node labs/idor/vulnerable.mjs and change the URL to match.

03

The bug

Authentication is not authorization.

The route proves that a session exists, parses the id, loads the invoice and sends it back. Every line is correct except the one that is absent: a comparison between the invoice owner and the session user.

Vulnerable build
const invoice = INVOICES.find((item) => item.id === Number(match[1])); if (!invoice) return sendJson(response, 404, { error: "not found" }); // ! no owner check: any session can read this objectreturn sendJson(response, 200, invoice);
Fixed build
const invoice = INVOICES.find((item) => item.id === Number(match[1])); if (!invoice) return sendJson(response, 404, { error: "not found" }); // + the check that was missingif (invoice.ownerId !== session.userId) {  return sendJson(response, 404, { error: "not found" });} return sendJson(response, 200, invoice);

The response is a valid 200 with a valid session. The caller simply has no relationship to the object. That is broken object-level authorization, or IDOR.

04

Impact

One changed number

Horizontal privilege escalation: any authenticated user can read another tenant's invoice by walking a predictable id range.

  • Confidentiality: customer name and amount of another tenant.

  • Enumeration: ids 1, 2, 3… are trivial to walk; the regression test does exactly that.

  • A hidden UI is no defence: the API answers even when no screen links to the object.

  • Silent by design: the vulnerable build logs a successful request, not an attack.

05

Root cause

Where the check belongs

Login answers who is calling. It cannot answer what they may read.

The ownership check belongs next to the lookup, in the data-access path, so every route that fetches an invoice inherits it. A UI guard or a role check farther upstream does not know that invoice 2 belongs to user 2.

01

Authentication

Is there a valid session?

Yes — user 1 (alice)

02

Authorization

May user 1 read invoice 2?

Never asked

06

Proof

Make the fix fail loudly

The regression test starts both builds and asserts five behaviours: the leak, the denial, and the three things the fix must not break.

  • Vulnerable build: Alice reads Bob's invoice (200).

  • Fixed build: the same request is hidden (404).

  • Fixed build: Alice still reads her own invoice (200).

  • Fixed build: anonymous access is rejected (401).

  • Fixed build: Bob still reads his own invoice (200).

Terminal
$ node labs/idor/test.mjs[vulnerable] alice -> invoice #2: 200 (Bob Srl)
[fixed] alice -> invoice #2: 404
[fixed] alice -> invoice #1: 200
[fixed] anonymous -> invoice #1: 401
[fixed] bob -> invoice #2: 200
PASS: 9 assertions

The test asserts the vulnerable state too. A test that only checks the fixed state can pass because the request never worked in the first place.

07

Review checklist

Five questions for every id in a route

What I now ask before calling an endpoint finished.

  1. 01

    Who is allowed to use this object id, and where is that written down?

  2. 02

    Does the query itself filter by owner, instead of a guard someone can forget?

  3. 03

    What happens with a second tenant's id — not just my own happy path?

  4. 04

    On denial, do I return 404 instead of confirming that the object exists?

  5. 05

    Is there a test that fails when the ownership check is removed?

Scope and honesty

What this page is not

This is a training lab, not a paid penetration test and not a real-world CVE. The app is deliberately vulnerable, binds only to loopback and uses synthetic data. It demonstrates method and remediation, not production impact. I built the target, so the exploit is authorized.

References

Read the source, not just the claim

Back to security notesSimulator