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);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
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.
Once a request has run, changing any option replays it with the new configuration.
Server trace
idle
- 01Session
- 02Handler
- 03Lookup
- 04Ownerskipped
GET /api/invoices/2$ GET /api/invoices/2
Alice reading invoice 2 is already set up. Press “Send request” to walk the trace.
—
…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.
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.
- 01
Get the code
Clone the repository and enter it. There is no install step: the lab is plain Node with no dependencies.
Terminalgit clone https://github.com/ErRusso/errusso.github.io.git cd errusso.github.io - 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.
Terminalnode labs/idor/vulnerable.mjsOutput
VULNERABLE training API: http://127.0.0.1:8787 Synthetic data only. Loopback only. Do not deploy. - 03
Log in as Alice
The session cookie is written to /tmp/idor.cookie. Alice is user 1; her invoices are 1 and 3.
Terminalcurl -s -c /tmp/idor.cookie \ -d 'username=alice&password=alice' \ http://127.0.0.1:8787/loginOutput
{"user":{"id":1,"username":"alice"}} - 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.
Terminalcurl -s -b /tmp/idor.cookie \ http://127.0.0.1:8787/api/invoices/2Output
{"id":2,"ownerId":2,"customer":"Bob Srl","amount":5400,"currency":"EUR"} - 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.
Terminalnode 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/2Output
404 - 06
Run the regression test
One command starts both builds and asserts the leak, the denial, owner access and anonymous 401 — in both directions.
Terminalnode labs/idor/test.mjsOutput
[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.
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.
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.
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.
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.
Authentication
Is there a valid session?
Yes — user 1 (alice)
Authorization
May user 1 read invoice 2?
Never asked
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).
$ 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 assertionsThe 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.
Review checklist
Five questions for every id in a route
What I now ask before calling an endpoint finished.
- 01
Who is allowed to use this object id, and where is that written down?
- 02
Does the query itself filter by owner, instead of a guard someone can forget?
- 03
What happens with a second tenant's id — not just my own happy path?
- 04
On denial, do I return 404 instead of confirming that the object exists?
- 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.