When an AI agent's tool trusts the object id it's handed and skips the ownership check, any user can read any tenant's data.
This is a hands-on lab for one of the most important — and most overlooked — auth bugs in modern LLM/agent systems: IDOR / Broken Object-Level Authorization (BOLA) in the tool-calling layer. Teams spend enormous effort hardening the model (prompt injection, jailbreaks) while the real breach is boring and classic: a backend tool that never checks whether the id it was given belongs to the caller.
⚠️ For education / authorized security training only. The app leaks data by design. Do not deploy it. All data (names, emails, phones, IPs, addresses) is 100% fictional, generated by a seeded PRNG.
The concrete example here — "PayPortal", a fake multi-tenant payment dashboard with an AI copilot — is an anonymized reproduction of a real, already-fixed bug-bounty finding. Company name, live endpoints, and real data are removed; what remains is the reusable pattern, which applies to any agent tool that takes an object id (order, invoice, ticket, user, document, file…).
An AI assistant is given a backend tool, query_api_record(orderId), wired directly to
a privileged lookup that can read every tenant's records. The tool checks that
you're logged in — but never checks that the orderId you asked for belongs to
you. Because ids are sequential and predictable, any single account can enumerate
them and exfiltrate all tenants' customer PII. The AI became a confused deputy: it
wields more privilege than the user driving it.
The normal app path (GET /api/orders) is correctly tenant-scoped — proving the flaw
lives only in how the agent's tool was wired, not in the model or the login.
The pattern, model-agnostic: an LLM may choose which tool to call, but it must never be what authorizes the call. Enforce object ownership inside the tool, from the authenticated session identity — never from model output or user-supplied arguments. Swap the mock engine for a real model (OpenAI, Llama, Claude, … — see below) and the bug is identical: a smarter model does not fix a missing authorization check.
npm install
npm start # -> http://localhost:8989 (PORT=xxxx to change)
# in another terminal:
npm run exploit # mass cross-tenant exfiltration PoC (vulnerable vs fixed)In the UI: toggle ❌ vulnerable / ✅ fixed to see the same attack succeed, then get blocked, live.
The Copilot ships with a deterministic mock engine (no key, offline). To prove the bug is in the tool wiring and not the model, you can plug in a real LLM — any OpenAI-compatible provider or local runtime:
cp .env.example .env # then fill in LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
npm start # a "🤖 live LLM" engine toggle now appears in the UIWorks with OpenAI, OpenRouter (one endpoint → Claude, Llama, GPT, Mistral, …),
Groq, Together, or a local Ollama / vLLM / LM Studio. The real model decides to
call query_api_record and picks the orderId — the tool still runs through the same
vulnerable/fixed resolve, so a smarter model does not fix the bug. That is the
lesson, made concrete.
| File | What it shows |
|---|---|
docs/VULN.md |
Root cause, before/after code, remediation, OWASP/CWE mapping |
docs/WALKTHROUGH.md |
Step-by-step guided demo (~10 min) |
src/copilot.ts |
runVulnerable vs runFixed — the whole lesson, side by side |
src/services.ts |
queryRawGlobal (privileged) vs queryScoped (ownership-checked) |
exploit/exploit.ts |
The enumeration attack, lab-safe |
src/
server.ts Express app + routes (login, scoped dashboard, SSE Copilot)
data.ts Multi-tenant fictional dataset (seeded, deterministic)
services.ts Backend services: privileged-global vs tenant-scoped
copilot.ts Agent orchestration + tool-calling (vulnerable & fixed wirings)
llm.ts Optional real-LLM engine (any OpenAI-compatible provider, via .env)
public/
index.html Chat UI with a vulnerable/fixed toggle
exploit/
exploit.ts Mass cross-tenant exfiltration PoC
docs/
VULN.md, WALKTHROUGH.md
An LLM may decide which tool to call, but it must never be what authorizes the call. Enforce object ownership inside the tool, from the authenticated session identity — never from model output or user-supplied arguments.
MIT — teaching material.